changes on global catalogue
This commit is contained in:
103
src/App.tsx
103
src/App.tsx
@@ -50,6 +50,7 @@ import StoreDetailView from './components/StoreDetailView';
|
||||
import LoginView from './components/LoginView';
|
||||
import UserStorePage from './components/UserStorePage';
|
||||
import AwaitingApi from './components/AwaitingApi';
|
||||
import ComparisonModal from './components/ComparisonModal';
|
||||
import type { AuthUser } from './services/auth';
|
||||
import ragulStoreCover from './assets/images/store_front_view_1780299351800.png';
|
||||
|
||||
@@ -249,13 +250,7 @@ export default function App() {
|
||||
const handleLogout = () => setAuthUser(null);
|
||||
|
||||
// Define secondary sections (Stores, Logistics, Staffing, Settings) within main body
|
||||
const renderSecondarySection = () => {
|
||||
switch (currentSection) {
|
||||
case 'stores': {
|
||||
// A single-store merchant has no branches, so skip the one-card registry
|
||||
// and open that store directly. Add a branch (2+ stores) and the grid
|
||||
// returns automatically. Clicking a card in multi-store mode also opens
|
||||
// the console, with a Back-to-registry button.
|
||||
const renderStoresSection = () => {
|
||||
const isSoleStore = !selectedStore && storesList.length === 1;
|
||||
const activeStore = selectedStore ?? (isSoleStore ? storesList[0] : null);
|
||||
|
||||
@@ -285,73 +280,77 @@ export default function App() {
|
||||
|
||||
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>
|
||||
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a]">
|
||||
Stores Registry
|
||||
</h1>
|
||||
<p className="text-zinc-500 font-sans text-xs mt-1">
|
||||
Local nodes registry, active manager assignments, and live dispatch and grocery delivery fulfillment statistics.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter control bar */}
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-4 bg-white p-4 rounded-xl border border-[#e2e8f0] shadow-sm mb-6">
|
||||
{/* Search input with search icon */}
|
||||
<div className="relative w-full md:max-w-md">
|
||||
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-zinc-400">
|
||||
<Search className="w-4 h-4" />
|
||||
</span>
|
||||
|
||||
{/* ── Search & Filter Toolbar ── */}
|
||||
<div className="flex flex-col lg:flex-row items-center gap-3 bg-white p-1.5 rounded-2xl border border-slate-200/70 shadow-sm mb-8 w-full">
|
||||
{/* Search Input */}
|
||||
<div className="relative flex-1 w-full group">
|
||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-slate-400 group-focus-within:text-purple-600 transition-colors">
|
||||
<Search className="w-5 h-5" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search stores by name, zone, or manager..."
|
||||
placeholder="Search store 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"
|
||||
className="w-full pl-11 pr-12 py-3 bg-transparent border-none text-sm font-semibold text-slate-800 placeholder-slate-400 focus:outline-none focus:ring-0 transition-all duration-300"
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
|
||||
{storesSearch && (
|
||||
<button
|
||||
onClick={() => setStoresSearch('')}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center text-zinc-400 hover:text-zinc-600"
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</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">
|
||||
{/* 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 md:flex-none px-4 py-1.5 rounded-md text-xs font-bold transition-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-[#662582] shadow-sm'
|
||||
: 'text-zinc-500 hover:text-zinc-800 hover:bg-white/40'
|
||||
? '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 ({storesList.length})
|
||||
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 md:flex-none px-4 py-1.5 rounded-md text-xs font-bold transition-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 === 'ACTIVE'
|
||||
? 'bg-white text-emerald-600 shadow-sm'
|
||||
: 'text-zinc-500 hover:text-emerald-600 hover:bg-white/40'
|
||||
? '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 ({storesList.filter(s => s.status.toLowerCase() === 'active').length})
|
||||
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 md:flex-none px-4 py-1.5 rounded-md text-xs font-bold transition-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 === 'INACTIVE'
|
||||
? 'bg-white text-rose-600 shadow-sm'
|
||||
: 'text-zinc-500 hover:text-rose-650 hover:bg-white/40'
|
||||
? '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 ({storesList.filter(s => s.status.toLowerCase() !== 'active').length})
|
||||
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>
|
||||
@@ -555,8 +554,10 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const renderSecondarySection = () => {
|
||||
switch (currentSection) {
|
||||
case 'settings':
|
||||
return <SettingsView tenantId={tenantId} user={authUser ?? undefined} />;
|
||||
|
||||
@@ -626,7 +627,16 @@ export default function App() {
|
||||
<div className="w-full p-container-margin md:p-xl space-y-lg transition-all duration-300">
|
||||
{/* Nav content routing */}
|
||||
{currentSection === 'dashboard' && (
|
||||
selectedStore ? (
|
||||
renderStoresSection()
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<DashboardView searchQuery={searchQuery} isCoimbatoreView={isCoimbatoreView} tenantId={tenantId} />
|
||||
<div>
|
||||
{renderStoresSection()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{currentSection === 'inventory' && (
|
||||
@@ -647,8 +657,8 @@ export default function App() {
|
||||
)}
|
||||
|
||||
|
||||
{/* Handle alternative sections: Stores, Settings */}
|
||||
{['stores', 'settings'].includes(currentSection) &&
|
||||
{/* Handle alternative sections: Settings */}
|
||||
{['settings'].includes(currentSection) &&
|
||||
renderSecondarySection()
|
||||
}
|
||||
</div>
|
||||
@@ -787,6 +797,7 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ComparisonModal />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ export default function DeliveriesView({ searchQuery = '', locationid, tenantId
|
||||
{ label: 'Total Deliveries', value: total.toLocaleString('en-IN'), color: '#6366f1', icon: <Truck size={20} />, badge: undefined },
|
||||
{ label: 'Pending', value: (summary?.pending ?? 0).toLocaleString('en-IN'), color: '#f59e0b', icon: <Clock size={20} />, badge: pct(summary?.pending ?? 0) },
|
||||
{ label: 'Delivered', value: (summary?.delivered ?? 0).toLocaleString('en-IN'), color: '#10b981', icon: <CheckCircle2 size={20} />, badge: pct(summary?.delivered ?? 0) },
|
||||
{ label: 'Cancelled', value: (summary?.cancelled ?? 0).toLocaleString('en-IN'), color: '#ef4444', icon: <XCircle size={20} />, badge: pct(summary?.cancelled ?? 0) },
|
||||
{ label: 'Cancelled', value: (summary?.cancelled ?? 0).toLocaleString('en-IN'), color: '#f43f5e', icon: <XCircle size={20} />, badge: pct(summary?.cancelled ?? 0) },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -16,11 +16,12 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { TrendingUp, Clock, CheckCircle2, IndianRupee, Bike, Truck, Calendar, Store } from 'lucide-react';
|
||||
import { useFiestaLocationSummary, useFiestaFleetSummary, useFiestaOrderSummary, useFiestaAllOrders, useFiestaRevenueSummary } from '../services/fiestaQueries';
|
||||
import { ResponsiveContainer, AreaChart, Area, XAxis, YAxis, Tooltip as RechartsTooltip, CartesianGrid } from 'recharts';
|
||||
import { ResponsiveContainer, AreaChart, Area, BarChart, Bar, PieChart, Pie, Cell, XAxis, YAxis, Tooltip as RechartsTooltip, CartesianGrid, Legend } from 'recharts';
|
||||
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
|
||||
import {
|
||||
GradientHeader, KpiStrip, Pill, StatusChip, MetricPill, FilterBar, TH_STYLE, TableShell,
|
||||
DELIVERY_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT, tint, soft, edge, ring,
|
||||
Skeleton, Tooltip
|
||||
} from './consoleUi';
|
||||
|
||||
type ReportTab = 'orders-summary' | 'riders-summary';
|
||||
@@ -50,11 +51,12 @@ export default function DeliveryReportsView({ searchQuery = '', tenantId = FIEST
|
||||
|
||||
return (
|
||||
<div className="animate-in fade-in duration-300">
|
||||
<GradientHeader title="Reports" subtitle="Operational analytics across outlets, riders, and the full order lifecycle." />
|
||||
|
||||
{/* Tab nav */}
|
||||
|
||||
{/* Tab nav & Date range combined */}
|
||||
<FilterBar className="mb-4">
|
||||
<div className="flex items-center gap-2 overflow-x-auto">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 overflow-x-auto shrink-0">
|
||||
{TABS.map((t) => {
|
||||
const Icon = t.icon;
|
||||
return (
|
||||
@@ -64,21 +66,19 @@ export default function DeliveryReportsView({ searchQuery = '', tenantId = FIEST
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</FilterBar>
|
||||
|
||||
{/* Shared date range */}
|
||||
<FilterBar className="mb-4">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-4 flex-wrap lg:justify-end min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] font-extrabold uppercase tracking-widest pr-1" style={{ color: TEXT_2 }}><Calendar size={13} style={{ color: BRAND }} /> Period</span>
|
||||
<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" style={{ padding: '6px 12px', border: `1.5px solid ${edge('#f59e0b')}`, background: tint('#f59e0b'), color: '#b45309' }} />
|
||||
<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" style={{ padding: '6px 12px', border: `1.5px solid ${edge('#f59e0b')}`, background: tint('#f59e0b'), color: '#b45309' }} />
|
||||
<input type="date" value={todate} min={fromdate} max={ymd(today)} onChange={(e) => setTodate(e.target.value)} className="rounded-full outline-none font-semibold w-28 sm:w-auto" style={{ padding: '6px 12px', border: `1.5px solid ${edge('#f59e0b')}`, background: tint('#f59e0b'), color: '#b45309' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FilterBar>
|
||||
@@ -143,35 +143,147 @@ function OrdersSummaryReport({ tenantId, locationid, fromdate, todate }: { tenan
|
||||
|
||||
const metricColor = metric === 'revenue' ? '#4f46e5' : '#0ea5e9';
|
||||
|
||||
// Computed Advanced Metrics
|
||||
const aov = totals.total > 0 ? totalRevenue / totals.total : 0;
|
||||
const cancelRate = totals.total > 0 ? (totals.cancelled / totals.total) * 100 : 0;
|
||||
|
||||
const hourlyData = useMemo(() => {
|
||||
const hours = new Array(24).fill(0);
|
||||
for (const r of (ordersQ.data ?? [])) {
|
||||
const dateVal = fstr(r.orderdate) || fstr(r.deliverydate) || fstr(r.createdat);
|
||||
if (!dateVal) continue;
|
||||
const timePart = dateVal.split('T')[1];
|
||||
if (timePart) {
|
||||
// Adjust based on the actual timezone if needed, assuming UTC -> local roughly or just raw hour
|
||||
const hour = parseInt(timePart.split(':')[0], 10);
|
||||
if (!isNaN(hour) && hour >= 0 && hour <= 23) {
|
||||
hours[hour] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Combine hours into something more readable or just raw 24h
|
||||
return hours.map((count, hour) => {
|
||||
const ampm = hour >= 12 ? 'PM' : 'AM';
|
||||
const h12 = hour % 12 || 12;
|
||||
return { hourLabel: `${h12}${ampm}`, count };
|
||||
});
|
||||
}, [ordersQ.data]);
|
||||
|
||||
const statusData = [
|
||||
{ name: 'Delivered', value: totals.delivered, color: '#10b981' },
|
||||
{ name: 'Processing', value: totals.total - totals.delivered - totals.pending - totals.cancelled, color: '#6366f1' },
|
||||
{ name: 'Pending', value: totals.pending, color: '#f59e0b' },
|
||||
{ name: 'Cancelled', value: totals.cancelled, color: '#ef4444' },
|
||||
].filter(d => d.value > 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<button onClick={() => setMetric('revenue')} className={`text-left p-5 bg-white rounded-2xl border transition-all duration-200 cursor-pointer flex items-center gap-4 ${metric === 'revenue' ? 'border-indigo-500 shadow-md ring-1 ring-indigo-500' : 'border-slate-200 shadow-sm hover:shadow-md'}`}>
|
||||
<div className={`p-3 rounded-xl ${metric === 'revenue' ? 'bg-indigo-100 text-indigo-600' : 'bg-slate-50 text-slate-400'}`}><IndianRupee size={20} /></div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-500">Revenue</p>
|
||||
<p className="text-2xl font-bold tracking-tight text-slate-900 mt-0.5">₹{totalRevenue.toLocaleString('en-IN')}</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
{/* Revenue */}
|
||||
<button onClick={() => setMetric('revenue')} className={`rounded-xl p-3 flex flex-col justify-between transition-all duration-300 text-left cursor-pointer outline-none relative group border ${metric === 'revenue' ? 'border-indigo-200 bg-white shadow-[0_8px_24px_rgba(99,102,241,0.12)] ring-1 ring-indigo-500/20 z-10' : 'border-slate-200/70 bg-white hover:border-indigo-200 hover:shadow-[0_8px_24px_rgba(99,102,241,0.06)] opacity-70 hover:opacity-100'}`}>
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<Tooltip content="Total revenue collected across all orders (excluding cancelled) in this period." position="bottom">
|
||||
<span className="text-[10px] text-slate-500 uppercase tracking-widest font-bold">Revenue</span>
|
||||
</Tooltip>
|
||||
<div className={`p-1.5 rounded-lg transition-transform group-hover:scale-110 flex items-center justify-center shrink-0 ${metric === 'revenue' ? 'bg-indigo-100 text-indigo-700' : 'bg-indigo-50 text-indigo-600'}`}>
|
||||
<IndianRupee className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 w-full">
|
||||
<h3 className="text-lg font-extrabold tracking-tight font-mono text-slate-900 truncate">
|
||||
₹{totalRevenue.toLocaleString('en-IN')}
|
||||
</h3>
|
||||
<p className="text-[10px] text-slate-400 font-semibold mt-0.5">Total collected</p>
|
||||
</div>
|
||||
</button>
|
||||
<button onClick={() => setMetric('orders')} className={`text-left p-5 bg-white rounded-2xl border transition-all duration-200 cursor-pointer flex items-center gap-4 ${metric === 'orders' ? 'border-sky-500 shadow-md ring-1 ring-sky-500' : 'border-slate-200 shadow-sm hover:shadow-md'}`}>
|
||||
<div className={`p-3 rounded-xl ${metric === 'orders' ? 'bg-sky-100 text-sky-600' : 'bg-slate-50 text-slate-400'}`}><TrendingUp size={20} /></div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-500">Total Orders</p>
|
||||
<p className="text-2xl font-bold tracking-tight text-slate-900 mt-0.5">{totals.total.toLocaleString('en-IN')}</p>
|
||||
|
||||
{/* Orders */}
|
||||
<button onClick={() => setMetric('orders')} className={`rounded-xl p-3 flex flex-col justify-between transition-all duration-300 text-left cursor-pointer outline-none relative group border ${metric === 'orders' ? 'border-sky-200 bg-white shadow-[0_8px_24px_rgba(14,165,233,0.12)] ring-1 ring-sky-500/20 z-10' : 'border-slate-200/70 bg-white hover:border-sky-200 hover:shadow-[0_8px_24px_rgba(14,165,233,0.06)] opacity-70 hover:opacity-100'}`}>
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<Tooltip content="Total number of orders placed in this period, regardless of current status." position="bottom">
|
||||
<span className="text-[10px] text-slate-500 uppercase tracking-widest font-bold">Total Orders</span>
|
||||
</Tooltip>
|
||||
<div className={`p-1.5 rounded-lg transition-transform group-hover:scale-110 flex items-center justify-center shrink-0 ${metric === 'orders' ? 'bg-sky-100 text-sky-700' : 'bg-sky-50 text-sky-600'}`}>
|
||||
<TrendingUp className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 w-full">
|
||||
<h3 className="text-lg font-extrabold tracking-tight font-mono text-slate-900 truncate">
|
||||
{totals.total.toLocaleString('en-IN')}
|
||||
</h3>
|
||||
<p className="text-[10px] text-slate-400 font-semibold mt-0.5">All statuses</p>
|
||||
</div>
|
||||
</button>
|
||||
<div className="p-5 bg-white rounded-2xl border border-slate-200 shadow-sm flex items-center gap-4">
|
||||
<div className="p-3 rounded-xl bg-amber-50 text-amber-600"><Clock size={20} /></div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-500">Pending</p>
|
||||
<p className="text-2xl font-bold tracking-tight text-slate-900 mt-0.5">{totals.pending.toLocaleString('en-IN')}</p>
|
||||
|
||||
{/* AOV */}
|
||||
<div className="rounded-xl p-3 flex flex-col justify-between transition-all duration-300 text-left relative group border border-slate-200/70 bg-white hover:border-violet-200 hover:shadow-[0_8px_24px_rgba(139,92,246,0.06)] opacity-70 hover:opacity-100">
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<Tooltip content="Average revenue generated per order (Total Revenue ÷ Total Orders)." position="bottom">
|
||||
<span className="text-[10px] text-slate-500 uppercase tracking-widest font-bold">Avg Order Value</span>
|
||||
</Tooltip>
|
||||
<div className="p-1.5 rounded-lg transition-transform group-hover:scale-110 flex items-center justify-center shrink-0 bg-violet-50 text-violet-600">
|
||||
<TrendingUp className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 bg-white rounded-2xl border border-slate-200 shadow-sm flex items-center gap-4">
|
||||
<div className="p-3 rounded-xl bg-emerald-50 text-emerald-600"><CheckCircle2 size={20} /></div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-500">Delivered</p>
|
||||
<p className="text-2xl font-bold tracking-tight text-slate-900 mt-0.5">{totals.delivered.toLocaleString('en-IN')}</p>
|
||||
<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>
|
||||
|
||||
{/* Delivered */}
|
||||
<div className="rounded-xl p-3 flex flex-col justify-between transition-all duration-300 text-left relative group border border-slate-200/70 bg-white hover:border-emerald-200 hover:shadow-[0_8px_24px_rgba(16,185,129,0.06)] opacity-70 hover:opacity-100">
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<Tooltip content="Total number of orders successfully delivered to the customer." position="bottom">
|
||||
<span className="text-[10px] text-slate-500 uppercase tracking-widest font-bold">Delivered</span>
|
||||
</Tooltip>
|
||||
<div className="p-1.5 rounded-lg transition-transform group-hover:scale-110 flex items-center justify-center shrink-0 bg-emerald-50 text-emerald-600">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 w-full">
|
||||
<h3 className="text-lg font-extrabold tracking-tight font-mono text-slate-900 truncate">
|
||||
{totals.delivered.toLocaleString('en-IN')}
|
||||
</h3>
|
||||
<p className="text-[10px] text-slate-400 font-semibold mt-0.5">Successfully fulfilled</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pending */}
|
||||
<div className="rounded-xl p-3 flex flex-col justify-between transition-all duration-300 text-left relative group border border-slate-200/70 bg-white hover:border-amber-200 hover:shadow-[0_8px_24px_rgba(245,158,11,0.06)] opacity-70 hover:opacity-100">
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<Tooltip content="Total number of orders currently being prepared, picked up, or in transit." position="bottom">
|
||||
<span className="text-[10px] text-slate-500 uppercase tracking-widest font-bold">Pending</span>
|
||||
</Tooltip>
|
||||
<div className="p-1.5 rounded-lg transition-transform group-hover:scale-110 flex items-center justify-center shrink-0 bg-amber-50 text-amber-600">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 w-full">
|
||||
<h3 className="text-lg font-extrabold tracking-tight font-mono text-slate-900 truncate">
|
||||
{totals.pending.toLocaleString('en-IN')}
|
||||
</h3>
|
||||
<p className="text-[10px] text-slate-400 font-semibold mt-0.5">In progress</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cancel Rate */}
|
||||
<div className="rounded-xl p-3 flex flex-col justify-between transition-all duration-300 text-left relative group border border-slate-200/70 bg-white hover:border-rose-200 hover:shadow-[0_8px_24px_rgba(244,63,94,0.06)] opacity-70 hover:opacity-100">
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<Tooltip content="Percentage of total orders that were cancelled (Cancelled ÷ Total Orders × 100)." position="bottom">
|
||||
<span className="text-[10px] text-slate-500 uppercase tracking-widest font-bold">Cancel Rate</span>
|
||||
</Tooltip>
|
||||
<div className={`p-1.5 rounded-lg transition-transform group-hover:scale-110 flex items-center justify-center shrink-0 ${cancelRate > 10 ? 'bg-rose-100 text-rose-700' : 'bg-slate-50 text-slate-500'}`}>
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 w-full">
|
||||
<h3 className={`text-lg font-extrabold tracking-tight font-mono truncate ${cancelRate > 10 ? 'text-rose-600' : 'text-slate-900'}`}>
|
||||
{cancelRate.toFixed(1)}%
|
||||
</h3>
|
||||
<p className="text-[10px] text-slate-400 font-semibold mt-0.5">Cancelled orders</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,30 +296,43 @@ function OrdersSummaryReport({ tenantId, locationid, fromdate, todate }: { tenan
|
||||
<th className="px-5 py-4 w-12">#</th>
|
||||
<th className="px-5 py-4">Outlet</th>
|
||||
<th className="px-5 py-4 text-right">Revenue</th>
|
||||
<th className="px-5 py-4 text-right">All</th>
|
||||
<th className="px-5 py-4 text-right">Created</th>
|
||||
<th className="px-5 py-4 text-right">Pending</th>
|
||||
<th className="px-5 py-4 text-right">Processing</th>
|
||||
<th className="px-5 py-4 text-right">Delivered</th>
|
||||
<th className="px-5 py-4 text-right">Cancelled</th>
|
||||
<th className="px-5 py-4 text-right">AOV</th>
|
||||
<th className="px-5 py-4 text-right">Orders</th>
|
||||
<th className="px-5 py-4 text-right">Fulfillment</th>
|
||||
<th className="px-5 py-4 text-right">Cancel Rate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{q.isLoading || revenueQ.isLoading ? <tr><td colSpan={9} className="px-5 py-12 text-center text-slate-500 font-medium">Loading outlet summary…</td></tr>
|
||||
: rows.length === 0 ? <tr><td colSpan={9} className="px-5 py-12 text-center text-slate-500 font-medium">No outlet data available.</td></tr>
|
||||
: rows.map((r, i) => (
|
||||
{q.isLoading || revenueQ.isLoading ? <tr><td colSpan={7} className="px-5 py-6"><div className="space-y-3"><Skeleton className="h-5 w-full" /><Skeleton className="h-5 w-full opacity-70" /><Skeleton className="h-5 w-full opacity-40" /></div></td></tr>
|
||||
: rows.length === 0 ? <tr><td colSpan={7} className="px-5 py-12 text-center text-slate-500 font-medium">No outlet data available.</td></tr>
|
||||
: rows.map((r, i) => {
|
||||
const rowRev = locationRevenueMap.get(fnum(r.locationid)) ?? 0;
|
||||
const rowAov = r.total > 0 ? rowRev / r.total : 0;
|
||||
const rowFulfill = r.total > 0 ? (r.delivered / r.total) * 100 : 0;
|
||||
const rowCancel = r.total > 0 ? (r.cancelled / r.total) * 100 : 0;
|
||||
return (
|
||||
<tr key={r.locationid || i} className="hover:bg-slate-50 transition-colors">
|
||||
<td className="px-5 py-3 text-slate-400 font-medium">{i + 1}</td>
|
||||
<td className="px-5 py-3 font-semibold text-slate-900">{r.locationname || `Location ${r.locationid}`}</td>
|
||||
<td className="px-5 py-3 text-right font-bold text-indigo-600">₹{(locationRevenueMap.get(fnum(r.locationid)) ?? 0).toLocaleString('en-IN')}</td>
|
||||
<td className="px-5 py-3 text-right font-bold text-indigo-600">₹{rowRev.toLocaleString('en-IN')}</td>
|
||||
<td className="px-5 py-3 text-right text-slate-600 font-medium">₹{Math.round(rowAov).toLocaleString('en-IN')}</td>
|
||||
<td className="px-5 py-3 text-right font-bold text-slate-900">{r.total.toLocaleString('en-IN')}</td>
|
||||
<td className="px-5 py-3 text-right"><Cnt n={r.created} color="#0ea5e9" /></td>
|
||||
<td className="px-5 py-3 text-right"><Cnt n={r.pending} color="#f59e0b" /></td>
|
||||
<td className="px-5 py-3 text-right"><Cnt n={r.processing} color="#6366f1" /></td>
|
||||
<td className="px-5 py-3 text-right"><Cnt n={r.delivered} color="#10b981" /></td>
|
||||
<td className="px-5 py-3 text-right"><Cnt n={r.cancelled} color="#ef4444" /></td>
|
||||
<td className="px-5 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<span className="text-xs font-bold text-emerald-600">{rowFulfill.toFixed(0)}%</span>
|
||||
<div className="w-16 h-1.5 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-emerald-500 rounded-full" style={{ width: `${rowFulfill}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-bold ${rowCancel > 10 ? 'bg-rose-100 text-rose-700' : 'bg-slate-100 text-slate-600'}`}>
|
||||
{rowCancel.toFixed(1)}%
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -222,16 +347,18 @@ function OrdersSummaryReport({ tenantId, locationid, fromdate, todate }: { tenan
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Trend Chart */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<TrendingUp size={20} className="text-indigo-500" />
|
||||
<h3 className="text-base font-bold text-slate-900 tracking-tight">Trend Analysis</h3>
|
||||
</div>
|
||||
{/* Insights Bento */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
|
||||
<div className="w-full h-80">
|
||||
{/* Trend Chart */}
|
||||
<div className="xl:col-span-2 bg-white rounded-2xl border border-slate-200 shadow-sm p-6 flex flex-col">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<TrendingUp size={18} className="text-indigo-500" />
|
||||
<h3 className="text-sm font-bold text-slate-900 tracking-tight uppercase">Performance Trend</h3>
|
||||
</div>
|
||||
<div className="w-full h-72">
|
||||
{ordersQ.isLoading ? (
|
||||
<div className="w-full h-full flex items-center justify-center text-sm font-medium text-slate-500">Loading trend data...</div>
|
||||
<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>
|
||||
) : (
|
||||
@@ -244,22 +371,22 @@ function OrdersSummaryReport({ tenantId, locationid, fromdate, todate }: { tenan
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="4 4" vertical={false} stroke="#e2e8f0" />
|
||||
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: '#64748b', fontWeight: 500 }} dy={10}
|
||||
<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: 12, fill: '#64748b', fontWeight: 500 }} dx={-10} tickFormatter={(v) => metric === 'revenue' ? `₹${v.toLocaleString('en-IN')}` : v.toLocaleString('en-IN')} />
|
||||
<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-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="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-2 text-sm font-medium text-slate-600 capitalize">
|
||||
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: metricColor }}></span>
|
||||
<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-lg font-bold text-slate-900">
|
||||
<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>
|
||||
@@ -269,12 +396,71 @@ function OrdersSummaryReport({ tenantId, locationid, fromdate, todate }: { tenan
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Area type="monotone" dataKey={metric} stroke={metricColor} strokeWidth={2.5} fillOpacity={1} fill="url(#colorGradient)" activeDot={{ r: 6, strokeWidth: 2, stroke: '#fff', fill: metricColor }} />
|
||||
<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>
|
||||
|
||||
{/* Breakdown Stack */}
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Status Breakdown */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-5 flex flex-col flex-1">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<CheckCircle2 size={16} className="text-emerald-500" />
|
||||
<h3 className="text-xs font-bold text-slate-900 tracking-tight uppercase">Order Status</h3>
|
||||
</div>
|
||||
{statusData.length > 0 ? (
|
||||
<div className="w-full h-40">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie data={statusData} cx="50%" cy="50%" innerRadius={35} outerRadius={60} dataKey="value" paddingAngle={3} stroke="none">
|
||||
{statusData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<RechartsTooltip formatter={(value: number) => value.toLocaleString('en-IN')} contentStyle={{ borderRadius: '12px', border: '1px solid #e2e8f0', boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)' }} itemStyle={{ fontWeight: 'bold' }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-xs text-slate-400">No data</div>
|
||||
)}
|
||||
<div className="flex flex-wrap justify-center gap-x-4 gap-y-2 mt-auto">
|
||||
{statusData.map(s => (
|
||||
<div key={s.name} className="flex items-center gap-1.5 text-[10px] font-bold text-slate-600">
|
||||
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: s.color }} /> {s.name} ({s.value})
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hourly Heatmap (Bar) */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-5 flex flex-col flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock size={16} className="text-amber-500" />
|
||||
<h3 className="text-xs font-bold text-slate-900 tracking-tight uppercase">Peak Hours</h3>
|
||||
</div>
|
||||
<div className="w-full h-40 mt-auto">
|
||||
{ordersQ.isLoading ? (
|
||||
<Skeleton className="w-full h-[240px] rounded-xl" />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={hourlyData} margin={{ top: 10, right: 0, left: -25, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
|
||||
<XAxis dataKey="hourLabel" axisLine={false} tickLine={false} tick={{ fontSize: 9, fill: '#94a3b8', fontWeight: 600 }} interval="preserveStartEnd" minTickGap={10} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 9, fill: '#94a3b8' }} />
|
||||
<RechartsTooltip cursor={{ fill: '#f8fafc' }} contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 6px -1px rgba(0,0,0,0.1)' }} />
|
||||
<Bar dataKey="count" fill="#6366f1" radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -301,7 +487,7 @@ function RidersSummaryReport({ fromdate, todate, tenantId, locationid }: { fromd
|
||||
<KpiStrip items={kpis} loading={q.isLoading} />
|
||||
<TableShell minWidth={820} head={['#', 'Rider', 'Orders', 'Pending', 'Cancelled', 'Delivered', 'KMs', 'Amount']}
|
||||
footer={mapped.length > 0 ? <TotalBar chips={[{ label: `${totals.orders} orders`, color: BRAND }, { label: `${totals.delivered} delivered`, color: '#10b981' }]} grand={`₹${totals.amount.toLocaleString('en-IN')}`} /> : undefined}>
|
||||
{q.isLoading ? <tr><td colSpan={8} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>Loading rider summary…</td></tr>
|
||||
{q.isLoading ? <tr><td colSpan={8} className="px-5 py-6"><div className="space-y-3"><Skeleton className="h-5 w-full" /><Skeleton className="h-5 w-full opacity-70" /><Skeleton className="h-5 w-full opacity-40" /></div></td></tr>
|
||||
: q.isError ? <tr><td colSpan={8} className="px-3 py-12 text-center text-xs" style={{ color: '#ef4444' }}>Rider summary unavailable for this period.</td></tr>
|
||||
: mapped.length === 0 ? <tr><td colSpan={8} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>No rider activity in this period.</td></tr>
|
||||
: mapped.map((r, i) => (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -140,10 +140,10 @@ export default function OrdersDeliveriesView({
|
||||
const pct = (n: number) => (total > 0 ? Math.round((n / total) * 100) : 0);
|
||||
|
||||
const kpis = [
|
||||
{ label: 'Created Orders', value: countFor('created').toLocaleString('en-IN'), color: '#475569', icon: <ShoppingBag size={20} />, badge: `${pct(countFor('created'))}% of total` },
|
||||
{ label: 'Pending Orders', value: countFor('pending').toLocaleString('en-IN'), color: '#9a6700', icon: <Clock size={20} />, badge: `${pct(countFor('pending'))}% of total` },
|
||||
{ label: 'Delivered Orders', value: countFor('delivered').toLocaleString('en-IN'), color: '#15803d', icon: <CheckCircle2 size={20} />, badge: `${pct(countFor('delivered'))}% of total` },
|
||||
{ label: 'Cancelled Orders', value: countFor('cancelled').toLocaleString('en-IN'), color: '#b42318', icon: <XCircle size={20} />, badge: `${pct(countFor('cancelled'))}% of total` },
|
||||
{ label: 'Created Orders', value: countFor('created').toLocaleString('en-IN'), color: '#6366f1', icon: <ShoppingBag size={20} />, badge: `${pct(countFor('created'))}% of total` },
|
||||
{ label: 'Pending Orders', value: countFor('pending').toLocaleString('en-IN'), color: '#f59e0b', icon: <Clock size={20} />, badge: `${pct(countFor('pending'))}% of total` },
|
||||
{ label: 'Delivered Orders', value: countFor('delivered').toLocaleString('en-IN'), color: '#10b981', icon: <CheckCircle2 size={20} />, badge: `${pct(countFor('delivered'))}% of total` },
|
||||
{ label: 'Cancelled Orders', value: countFor('cancelled').toLocaleString('en-IN'), color: '#f43f5e', icon: <XCircle size={20} />, badge: `${pct(countFor('cancelled'))}% of total` },
|
||||
];
|
||||
|
||||
// CSV export
|
||||
|
||||
@@ -198,10 +198,10 @@ export default function OrdersView({ searchQuery = '', locationid, tenantId = FI
|
||||
// Restrained, professional palette — deep muted tones (not neon) so the KPI
|
||||
// strip reads as a serious business dashboard rather than a colourful one.
|
||||
const kpis = [
|
||||
{ label: 'Created Orders', value: (summary?.created ?? 0).toLocaleString('en-IN'), color: '#475569', icon: <ShoppingBag size={20} />, badge: `${pct(summary?.created ?? 0)}% of total` },
|
||||
{ label: 'Pending Orders', value: (summary?.pending ?? 0).toLocaleString('en-IN'), color: '#9a6700', icon: <Clock size={20} />, badge: `${pct(summary?.pending ?? 0)}% of total` },
|
||||
{ label: 'Delivered Orders', value: (summary?.delivered ?? 0).toLocaleString('en-IN'), color: '#15803d', icon: <CheckCircle2 size={20} />, badge: `${pct(summary?.delivered ?? 0)}% of total` },
|
||||
{ label: 'Cancelled Orders', value: (summary?.cancelled ?? 0).toLocaleString('en-IN'), color: '#b42318', icon: <XCircle size={20} />, badge: `${pct(summary?.cancelled ?? 0)}% of total` },
|
||||
{ label: 'Created Orders', value: (summary?.created ?? 0).toLocaleString('en-IN'), color: '#6366f1', icon: <ShoppingBag size={20} />, badge: `${pct(summary?.created ?? 0)}% of total` },
|
||||
{ label: 'Pending Orders', value: (summary?.pending ?? 0).toLocaleString('en-IN'), color: '#f59e0b', icon: <Clock size={20} />, badge: `${pct(summary?.pending ?? 0)}% of total` },
|
||||
{ label: 'Delivered Orders', value: (summary?.delivered ?? 0).toLocaleString('en-IN'), color: '#10b981', icon: <CheckCircle2 size={20} />, badge: `${pct(summary?.delivered ?? 0)}% of total` },
|
||||
{ label: 'Cancelled Orders', value: (summary?.cancelled ?? 0).toLocaleString('en-IN'), color: '#f43f5e', icon: <XCircle size={20} />, badge: `${pct(summary?.cancelled ?? 0)}% of total` },
|
||||
];
|
||||
|
||||
const setScope = (next: Partial<{ status: StatusKey; from: string; to: string }>) => {
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
MapPin,
|
||||
Calendar,
|
||||
Trophy,
|
||||
Store
|
||||
Store,
|
||||
CheckCircle2
|
||||
} from 'lucide-react';
|
||||
import { LeaderboardNode } from '../types';
|
||||
import {
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
useFiestaOrderSummary,
|
||||
useFiestaRevenueSummary,
|
||||
useFiestaTimeSeries,
|
||||
useFiestaStoresTimeSeries,
|
||||
} from '../services/fiestaQueries';
|
||||
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, num as fnum, str as fstr, ymd } from '../services/fiestaApi';
|
||||
import { stockRowToProduct } from '../services/fiestaMappers';
|
||||
@@ -283,38 +285,48 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
|
||||
|
||||
|
||||
// Leaderboard — outlets ranked by total live orders.
|
||||
// Monthly order distribution per outlet — includes all stores.
|
||||
// Stores with no orders will simply show 0s across the board.
|
||||
const insightRows = (() => {
|
||||
return filteredLocations.map(loc => {
|
||||
const locName = loc.locationname || `Location ${loc.locationid}`;
|
||||
// Find the corresponding row from insightQ.data
|
||||
const insightData = (insightQ.data ?? []).find(
|
||||
r => (fstr(r.locationname) || `Location ${fstr(r.locationid)}`) === locName
|
||||
);
|
||||
|
||||
return {
|
||||
name: locName,
|
||||
months: (insightData?.ordermonths ?? {}) as Record<string, unknown>,
|
||||
};
|
||||
});
|
||||
})();
|
||||
|
||||
// Leaderboard — outlets ranked by current month's live orders.
|
||||
const leaderboard: LeaderboardNode[] = (() => {
|
||||
let rows = [...filteredLocations];
|
||||
rows = rows.sort((a, b) => b.total - a.total).slice(0, 4);
|
||||
const currentMonthIdx = new Date().getMonth();
|
||||
const currentMonthKey = MONTH_KEYS[currentMonthIdx];
|
||||
|
||||
let rows = filteredLocations.map(loc => {
|
||||
const name = loc.locationname || `Location ${loc.locationid}`;
|
||||
// Find the corresponding insight row for this store
|
||||
const insightRow = insightRows.find(r => r.name === name);
|
||||
const total = insightRow ? fnum(insightRow.months[currentMonthKey]) : 0;
|
||||
|
||||
return { name, total };
|
||||
});
|
||||
|
||||
rows = rows.sort((a, b) => b.total - a.total);
|
||||
const max = rows.length ? rows[0].total : 0;
|
||||
return rows.map((r, i) => ({
|
||||
rank: String(i + 1).padStart(2, '0'),
|
||||
name: r.locationname || `Location ${r.locationid}`,
|
||||
name: r.name,
|
||||
percentage: max > 0 ? Math.round((r.total / max) * 100) : 0,
|
||||
// Live order count drives the ranking/bar. No per-node revenue API yet, so the
|
||||
// label shows the real order count (not fabricated rupees) — revenue lands with [R1].
|
||||
revenue: `${r.total.toLocaleString()} ord`,
|
||||
}));
|
||||
})();
|
||||
const currentLeaderboard = leaderboard;
|
||||
|
||||
// Monthly order distribution per outlet — live only (useFiestaOrderInsight
|
||||
// already covers all the tenant's locations). The Chennai/Bangalore stub rows
|
||||
// were removed; selecting those regions filters the live rows to none.
|
||||
const insightRows = (() => {
|
||||
let rows = (insightQ.data ?? []);
|
||||
if (selectedRegion === 'coimbatore') {
|
||||
rows = rows.filter(r => isCoimbatoreNode(fstr(r.locationname)));
|
||||
} else if (selectedRegion === 'chennai' || selectedRegion === 'bangalore') {
|
||||
rows = [];
|
||||
}
|
||||
return rows.map((r) => ({
|
||||
name: fstr(r.locationname) || `Location ${fstr(r.locationid)}`,
|
||||
months: (r.ordermonths ?? {}) as Record<string, unknown>,
|
||||
}));
|
||||
})();
|
||||
|
||||
const heatmapMax = Math.max(
|
||||
1,
|
||||
...insightRows.flatMap((row) => MONTH_KEYS.map((k) => fnum(row.months[k]))),
|
||||
@@ -426,15 +438,23 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
return ymd(d);
|
||||
}, [chartTimeframe]);
|
||||
|
||||
const tsQ = useFiestaTimeSeries({
|
||||
const tsQ_all = useFiestaTimeSeries({
|
||||
tenantid: tenantId,
|
||||
granularity: tsGranularity,
|
||||
fromdate: tsFromDate,
|
||||
todate: todate,
|
||||
});
|
||||
|
||||
const tsQ_stores = useFiestaStoresTimeSeries(
|
||||
tenantId,
|
||||
mappedLocations,
|
||||
tsGranularity,
|
||||
tsFromDate,
|
||||
todate
|
||||
);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
const data = tsQ.data || [];
|
||||
const data = tsQ_all.data || [];
|
||||
if (data.length === 0) return [{ label: 'No Data', value: 0 }];
|
||||
|
||||
return data.map((d: any) => {
|
||||
@@ -461,49 +481,13 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
value: val
|
||||
};
|
||||
});
|
||||
}, [tsQ.data, chartMetric, tsGranularity]);
|
||||
}, [tsQ_all.data, tsQ_stores.data, isAllRegions, chartMetric, tsGranularity]);
|
||||
|
||||
const maxChartVal = useMemo(() => Math.max(...chartData.map(d => d.value), 1), [chartData]);
|
||||
|
||||
return (
|
||||
<div className="space-y-lg animate-in fade-in duration-500 font-sans relative">
|
||||
<div className="space-y-lg animate-in fade-in duration-500 font-sans">
|
||||
|
||||
{/* Immersive Background Blur Blobs */}
|
||||
<div className="absolute top-10 left-1/4 w-96 h-96 bg-purple-400/5 rounded-full blur-[120px] pointer-events-none -z-10 animate-pulse" />
|
||||
<div className="absolute top-40 right-1/4 w-[28rem] h-[28rem] bg-indigo-400/5 rounded-full blur-[140px] pointer-events-none -z-10 animate-pulse" style={{ animationDuration: '8s' }} />
|
||||
|
||||
{/* ── Immersive Analytics Banner (With Data Cover Image & Slate Gradient Overlay) ── */}
|
||||
<div className="relative rounded-2xl p-6 md:p-8 text-white shadow-xl border border-purple-500/20 mb-8 animate-in fade-in duration-300 z-40">
|
||||
{/* Cover Image Background & Decor (wrapped in overflow-hidden to keep rounded corner clip, while allowing dropdown overflow) */}
|
||||
<div className="absolute inset-0 z-0 overflow-hidden rounded-2xl">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1551288049-bebda4e38f71?auto=format&fit=crop&w=1200&q=80"
|
||||
alt="Reports Dashboard Banner"
|
||||
className="w-full h-full object-cover object-center opacity-40"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-slate-950 via-slate-900/90 to-purple-950/80" />
|
||||
|
||||
{/* Background decorative glowing circles */}
|
||||
<div className="absolute top-0 right-0 w-72 h-72 bg-purple-500/10 rounded-full blur-3xl -mr-20 -mt-20 pointer-events-none z-0" />
|
||||
<div className="absolute bottom-0 left-0 w-56 h-56 bg-slate-500/5 rounded-full blur-2xl -ml-20 -mb-20 pointer-events-none z-0" />
|
||||
</div>
|
||||
|
||||
{/* Content Row */}
|
||||
<div className="relative z-20 flex flex-col md:flex-row md:items-center justify-between gap-lg">
|
||||
<div>
|
||||
<h1 className="font-sans font-bold text-2xl tracking-tight text-white flex items-center gap-2">
|
||||
Business Intelligence Center
|
||||
<span className="text-[10px] text-purple-200 font-bold bg-purple-900/60 border border-purple-500/30 px-2 py-0.5 rounded-full uppercase tracking-wider animate-pulse">
|
||||
Live Core
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Primary KPI Row - 4 Key Tab buttons with Sparklines */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-gutter text-xs font-sans relative z-10">
|
||||
@@ -519,91 +503,76 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
|
||||
if (kpi.color === 'indigo') {
|
||||
activeStyles = isActive
|
||||
? 'border-indigo-500 bg-indigo-50/10 shadow-[0_12px_30px_rgba(99,102,241,0.15)] ring-1 ring-indigo-500 scale-102 z-10'
|
||||
: 'hover:border-indigo-200 border-[#e2e8f0] bg-white/70 hover:bg-white hover:shadow-[0_8px_20px_rgba(99,102,241,0.05)]';
|
||||
? 'border-indigo-200 bg-white shadow-[0_8px_24px_rgba(99,102,241,0.12)] ring-1 ring-indigo-500/20 z-10'
|
||||
: 'border-slate-200/70 bg-white hover:border-indigo-200 hover:shadow-[0_8px_24px_rgba(99,102,241,0.06)] opacity-70 hover:opacity-100';
|
||||
strokeColor = '#6366f1';
|
||||
IconComponent = ShoppingBag;
|
||||
iconBg = 'bg-indigo-50 border border-indigo-100 shadow-[0_0_12px_rgba(99,102,241,0.2)]';
|
||||
iconTextColor = 'text-indigo-600';
|
||||
iconBg = 'bg-indigo-50 text-indigo-600 ring-1 ring-indigo-100';
|
||||
} else if (kpi.color === 'emerald') {
|
||||
activeStyles = isActive
|
||||
? 'border-emerald-500 bg-emerald-50/10 shadow-[0_12px_30px_rgba(16,185,129,0.15)] ring-1 ring-emerald-500 scale-102 z-10'
|
||||
: 'hover:border-emerald-200 border-[#e2e8f0] bg-white/70 hover:bg-white hover:shadow-[0_8px_20px_rgba(16,185,129,0.05)]';
|
||||
? 'border-emerald-200 bg-white shadow-[0_8px_24px_rgba(16,185,129,0.12)] ring-1 ring-emerald-500/20 z-10'
|
||||
: 'border-slate-200/70 bg-white hover:border-emerald-200 hover:shadow-[0_8px_24px_rgba(16,185,129,0.06)] opacity-70 hover:opacity-100';
|
||||
strokeColor = '#10b981';
|
||||
IconComponent = Activity;
|
||||
iconBg = 'bg-emerald-50 border border-emerald-100 shadow-[0_0_12px_rgba(16,185,129,0.2)]';
|
||||
iconTextColor = 'text-emerald-600';
|
||||
iconBg = 'bg-emerald-50 text-emerald-600 ring-1 ring-emerald-100';
|
||||
} else if (kpi.color === 'rose') {
|
||||
activeStyles = isActive
|
||||
? 'border-rose-500 bg-rose-50/10 shadow-[0_12px_30px_rgba(244,63,94,0.15)] ring-1 ring-rose-500 scale-102 z-10'
|
||||
: 'hover:border-rose-200 border-[#e2e8f0] bg-white/70 hover:bg-white hover:shadow-[0_8px_20px_rgba(244,63,94,0.05)]';
|
||||
? 'border-rose-200 bg-white shadow-[0_8px_24px_rgba(244,63,94,0.12)] ring-1 ring-rose-500/20 z-10'
|
||||
: 'border-slate-200/70 bg-white hover:border-rose-200 hover:shadow-[0_8px_24px_rgba(244,63,94,0.06)] opacity-70 hover:opacity-100';
|
||||
strokeColor = '#f43f5e';
|
||||
IconComponent = AlertTriangle;
|
||||
iconBg = 'bg-rose-50 border border-rose-100 shadow-[0_0_12px_rgba(244,63,94,0.2)]';
|
||||
iconTextColor = 'text-rose-600';
|
||||
iconBg = 'bg-rose-50 text-rose-600 ring-1 ring-rose-100';
|
||||
} else {
|
||||
activeStyles = isActive
|
||||
? 'border-sky-500 bg-sky-50/10 shadow-[0_12px_30px_rgba(14,165,233,0.15)] ring-1 ring-sky-500 scale-102 z-10'
|
||||
: 'hover:border-sky-200 border-[#e2e8f0] bg-white/70 hover:bg-white hover:shadow-[0_8px_20px_rgba(14,165,233,0.05)]';
|
||||
? 'border-sky-200 bg-white shadow-[0_8px_24px_rgba(14,165,233,0.12)] ring-1 ring-sky-500/20 z-10'
|
||||
: 'border-slate-200/70 bg-white hover:border-sky-200 hover:shadow-[0_8px_24px_rgba(14,165,233,0.06)] opacity-70 hover:opacity-100';
|
||||
strokeColor = '#0ea5e9';
|
||||
IconComponent = Package;
|
||||
iconBg = 'bg-sky-50 border border-sky-100 shadow-[0_0_12px_rgba(14,165,233,0.2)]';
|
||||
iconTextColor = 'text-sky-600';
|
||||
iconBg = 'bg-sky-50 text-sky-600 ring-1 ring-sky-100';
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={kpi.id}
|
||||
onClick={() => setChartMetric(kpi.id)}
|
||||
className={`border backdrop-blur-md rounded-2xl p-3 flex flex-col justify-between hover:shadow-lg transition-all duration-300 hover:-translate-y-1.5 text-left cursor-pointer outline-none relative overflow-hidden group ${activeStyles}`}
|
||||
className={`rounded-xl p-3 flex flex-col justify-between transition-all duration-300 text-left cursor-pointer outline-none relative group border ${activeStyles}`}
|
||||
>
|
||||
{/* Subtle top glowing line when active */}
|
||||
{isActive && (
|
||||
<div className="absolute top-0 left-0 right-0 h-1" style={{ backgroundColor: strokeColor }} />
|
||||
)}
|
||||
|
||||
{/* Header Row */}
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<span className="text-[10px] font-sans font-bold text-zinc-400 uppercase tracking-widest block">
|
||||
{kpi.title}
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<span className="text-[10px] text-slate-500 uppercase tracking-widest font-bold">{kpi.title}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{kpi.trend && (
|
||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-md flex items-center gap-1 leading-none ${kpi.isPositive ? 'bg-emerald-50 text-emerald-700' : 'bg-rose-50 text-rose-700'}`}>
|
||||
{kpi.isPositive ? <TrendingUp size={10} /> : <TrendingDown size={10} />}
|
||||
{kpi.trend}
|
||||
</span>
|
||||
<div className={`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 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>
|
||||
|
||||
{/* Main Metric Value and Trend Badge */}
|
||||
<div className="mt-2 flex justify-between items-end w-full">
|
||||
<div>
|
||||
{kpi.awaiting ? (
|
||||
<div className="mt-1">
|
||||
<AwaitingApi label="Revenue" api="[R1]" compact />
|
||||
</div>
|
||||
) : (
|
||||
<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">
|
||||
<h3 className="text-lg font-extrabold tracking-tight font-mono text-slate-900">
|
||||
{kpi.value}
|
||||
</div>
|
||||
{kpi.trend && (
|
||||
<span className={`text-[9px] font-bold px-2 py-0.5 rounded-full flex items-center gap-0.5 leading-none h-4 ${kpi.isPositive ? 'bg-emerald-50 text-emerald-600 border border-emerald-100/50' : 'bg-rose-50 text-rose-600 border border-rose-100/50'
|
||||
}`}>
|
||||
{kpi.isPositive ? '▲' : '▼'}
|
||||
{kpi.trend}
|
||||
</span>
|
||||
</h3>
|
||||
)}
|
||||
<p className="text-[10px] text-slate-400 font-semibold mt-0.5">{kpi.status}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom Sparkline & Subtext segment */}
|
||||
<div className="flex items-center justify-between mt-auto pt-2 w-full border-t border-[#f1f5f9]">
|
||||
<span className="text-[10px] text-zinc-550 font-bold uppercase tracking-wider">{kpi.status}</span>
|
||||
|
||||
{/* KPI Mini Sparkline Chart */}
|
||||
<div className="w-16 h-7 select-none shrink-0">
|
||||
<div className="w-12 h-6 select-none shrink-0 opacity-80 group-hover:opacity-100 transition-opacity">
|
||||
<svg className="w-full h-full" viewBox="0 0 64 32">
|
||||
<path
|
||||
d={getSparkPath(kpi.spark, 64, 32)}
|
||||
fill="none"
|
||||
stroke={strokeColor}
|
||||
strokeWidth="2"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
@@ -614,143 +583,33 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Main Interactive Charts & Insights Bento Segment */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-gutter text-xs font-sans relative z-10">
|
||||
|
||||
{/* Interactive Main Graph Card — Full width (12 cols) */}
|
||||
<div className="lg:col-span-12 bg-white/70 backdrop-blur-md border border-[#e2e8f0] rounded-3xl p-lg shadow-sm flex flex-col justify-between relative overflow-hidden group">
|
||||
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-lg border-b border-[#f1f5f9] pb-md">
|
||||
<div>
|
||||
<span className="text-[10px] text-purple-650 font-bold bg-purple-50 px-2 py-0.5 rounded-full uppercase tracking-wider">
|
||||
Interactive Graph
|
||||
</span>
|
||||
<h3 className="font-sans font-bold text-base text-[#0f172a] mt-1.5">
|
||||
{chartMetric === 'orders' ? 'Total Orders Velocity Trend' :
|
||||
chartMetric === 'revenue' ? 'Revenue Expansion Trajectory' :
|
||||
chartMetric === 'cancelled' ? 'Order Cancellation Frequency' :
|
||||
'Catalogue Active SKUs Growth'}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex bg-slate-100/50 p-1 rounded-xl border border-slate-200/60 hidden sm:flex">
|
||||
{(['day', 'week', 'month'] as const).map((tf) => (
|
||||
<button
|
||||
key={tf}
|
||||
onClick={() => setChartTimeframe(tf)}
|
||||
className={`px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-all ${
|
||||
chartTimeframe === tf
|
||||
? 'bg-white text-purple-700 shadow-sm border border-purple-100'
|
||||
: 'text-slate-500 hover:text-slate-700 hover:bg-slate-200/50'
|
||||
}`}
|
||||
>
|
||||
{tf}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<span className="text-[10px] font-mono text-zinc-555 font-bold bg-zinc-50 border border-zinc-200/60 px-3 py-1.5 rounded-xl flex items-center gap-1.5 ml-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-current animate-pulse" style={{ color: theme.activeLine }} />
|
||||
<span>Live Sync</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Plotted Area */}
|
||||
<div className="relative h-64 select-none w-full flex items-end pt-8 pb-4 px-2">
|
||||
<svg className="absolute inset-0 w-full h-full preserve-3d" preserveAspectRatio="none">
|
||||
<defs>
|
||||
<linearGradient id="chartGradient" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor={theme.activeLine} stopOpacity={0.4} />
|
||||
<stop offset="100%" stopColor={theme.activeLine} stopOpacity={0.01} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d={`M 0,100 ` + chartData.map((d, i) => {
|
||||
const x = (i / (chartData.length - 1)) * 100;
|
||||
const y = 100 - ((d.value / maxChartVal) * 80);
|
||||
return `L ${x},${y} `;
|
||||
}).join('') + `L 100,100 Z`}
|
||||
fill="url(#chartGradient)"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<path
|
||||
d={chartData.map((d, i) => {
|
||||
const x = (i / (chartData.length - 1)) * 100;
|
||||
const y = 100 - ((d.value / maxChartVal) * 80);
|
||||
return `${i === 0 ? 'M' : 'L'} ${x},${y}`;
|
||||
}).join(' ')}
|
||||
fill="none"
|
||||
stroke={theme.activeLine}
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
className="drop-shadow-[0_4px_6px_rgba(168,85,247,0.4)]"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div className="absolute inset-0 flex justify-between items-end pb-4 px-2">
|
||||
{chartData.map((d, i) => (
|
||||
<div key={i} className="group relative flex flex-col items-center flex-1 h-full justify-end cursor-crosshair">
|
||||
{/* Hover vertical line */}
|
||||
<div className="absolute top-0 bottom-6 w-px bg-slate-200/0 group-hover:bg-slate-300/50 transition-colors" />
|
||||
|
||||
{/* Invisible hit area */}
|
||||
<div className="absolute inset-0 w-full h-full z-10" />
|
||||
|
||||
{/* Tooltip */}
|
||||
<div className="absolute bottom-full mb-2 opacity-0 group-hover:opacity-100 transition-all duration-200 bg-slate-900 text-white text-[10px] px-3 py-2 rounded-xl shadow-xl pointer-events-none whitespace-nowrap z-20 flex flex-col items-center">
|
||||
<span className="font-bold text-xs text-purple-300">{d.label}</span>
|
||||
<span className="font-extrabold tracking-wide mt-0.5">
|
||||
{chartMetric === 'revenue' ? `₹${d.value.toLocaleString()}` : d.value.toLocaleString()}
|
||||
</span>
|
||||
{/* Tooltip caret */}
|
||||
<div className="absolute top-full w-2 h-2 bg-slate-900 rotate-45 -mt-1" />
|
||||
</div>
|
||||
|
||||
{/* Dot on line */}
|
||||
<div
|
||||
className="absolute w-3 h-3 rounded-full border-2 border-white bg-purple-600 shadow-md opacity-0 group-hover:opacity-100 transition-all duration-200 z-10 scale-50 group-hover:scale-100"
|
||||
style={{ bottom: `calc(${((d.value / maxChartVal) * 80)}% + 1.5rem)`, backgroundColor: theme.activeLine }}
|
||||
/>
|
||||
|
||||
{/* X-axis label */}
|
||||
<span className="text-[9px] font-bold text-slate-400 mt-2 rotate-0 truncate max-w-full px-1">{d.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Bento split maps (Heatmap grid & Leaderboard) */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-gutter text-xs font-sans relative z-10">
|
||||
|
||||
{/* Revenue Heatmap table - 8 Cols */}
|
||||
<div className="lg:col-span-8 bg-white/70 backdrop-blur-md border border-[#e2e8f0] rounded-2xl overflow-hidden flex flex-col justify-between shadow-sm">
|
||||
<div className="lg:col-span-8 bg-white border border-slate-200/70 rounded-2xl overflow-hidden flex flex-col justify-between shadow-[0_1px_3px_rgba(16,24,40,0.05)] hover:-translate-y-1 hover:shadow-xl transition-all duration-300">
|
||||
|
||||
<div className="bg-[#f8fafc] border-b border-[#e2e8f0] px-md py-sm flex justify-between items-center select-none">
|
||||
<span className="text-[11px] font-sans font-bold text-[#0f172a] uppercase tracking-widest block">
|
||||
<div className="bg-slate-50/50 border-b border-slate-100 px-5 py-4 flex justify-between items-center select-none">
|
||||
<span className="text-[11px] font-sans font-bold text-slate-800 uppercase tracking-widest block">
|
||||
Monthly Order Distribution
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2 text-[9px] font-bold text-zinc-400 uppercase tracking-tight">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#662582] animate-pulse"></span>
|
||||
<div className="flex items-center gap-2 text-[10px] font-bold text-slate-400 uppercase tracking-tight">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-purple-600 animate-pulse"></span>
|
||||
<span>Busiest Month</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-md flex-1 overflow-x-auto">
|
||||
<div className="p-5 flex-1 overflow-x-auto">
|
||||
{selectedCell && (
|
||||
<div className="mb-4 p-md bg-purple-500/10 border border-purple-500/20 backdrop-blur-md rounded-2xl text-xs flex justify-between items-center animate-in slide-in-from-top-2 duration-300">
|
||||
<div className="mb-4 p-4 bg-purple-50 border border-purple-100 rounded-xl text-xs flex justify-between items-center animate-in slide-in-from-top-2 duration-300">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 rounded-full bg-purple-500/20 flex items-center justify-center text-purple-650 shrink-0">
|
||||
<div className="w-7 h-7 rounded-full bg-purple-100 flex items-center justify-center text-purple-600 shrink-0">
|
||||
<Info size={14} />
|
||||
</div>
|
||||
<span className="font-sans text-purple-955 font-semibold">
|
||||
<span className="font-sans text-purple-900 font-semibold">
|
||||
{selectedCell.day} · {selectedCell.hour} : {selectedCell.val} orders
|
||||
</span>
|
||||
</div>
|
||||
@@ -764,16 +623,16 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
)}
|
||||
|
||||
{insightRows.length === 0 ? (
|
||||
<div className="py-xl text-center text-zinc-400 text-xs">
|
||||
<div className="py-12 text-center text-slate-400 text-xs">
|
||||
{insightQ.isLoading ? 'Loading monthly order distribution…' : 'No order insight available for this region.'}
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-center border-collapse text-xs font-sans">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="p-xs text-[9px] font-bold text-zinc-400 w-32 uppercase italic text-left pl-md">Outlet Name</th>
|
||||
<th className="p-2 text-[9px] font-bold text-slate-400 w-32 uppercase italic text-left pl-4">Outlet Name</th>
|
||||
{MONTH_LABELS.map((m) => (
|
||||
<th key={m} className="p-sm text-[10px] font-bold text-[#0f172a] border-b border-[#f1f5f9]">
|
||||
<th key={m} className="p-2 text-[10px] font-bold text-slate-800 border-b border-slate-100">
|
||||
{m}
|
||||
</th>
|
||||
))}
|
||||
@@ -782,7 +641,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
<tbody>
|
||||
{insightRows.map((row, idx) => (
|
||||
<tr key={idx}>
|
||||
<td className="p-sm text-left font-bold text-[#0f172a] tracking-wide truncate max-w-[8rem] border-r border-[#f1f5f9] pl-md">
|
||||
<td className="p-2 text-left font-bold text-slate-800 tracking-wide truncate max-w-[8rem] border-r border-slate-100 pl-4">
|
||||
{row.name}
|
||||
</td>
|
||||
{MONTH_KEYS.map((key, mIdx) => {
|
||||
@@ -807,16 +666,16 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
</div>
|
||||
|
||||
{/* Leaderboard nodes bar list - 4 Cols */}
|
||||
<div className="lg:col-span-4 bg-white/70 backdrop-blur-md border border-[#e2e8f0] rounded-2xl flex flex-col shadow-sm">
|
||||
<div className="bg-[#f8fafc] border-b border-[#e2e8f0] px-md py-sm select-none">
|
||||
<span className="text-[11px] font-sans font-bold text-[#0f172a] uppercase tracking-widest block">
|
||||
<div className="lg:col-span-4 bg-white border border-slate-200/70 rounded-2xl flex flex-col shadow-[0_1px_3px_rgba(16,24,40,0.05)] hover:-translate-y-1 hover:shadow-xl transition-all duration-300">
|
||||
<div className="bg-slate-50/50 border-b border-slate-100 px-5 py-4 select-none">
|
||||
<span className="text-[11px] font-sans font-bold text-slate-800 uppercase tracking-widest block">
|
||||
Top Performing Nodes
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-md flex-1 space-y-md flex flex-col justify-center">
|
||||
<div className="p-5 flex-1 space-y-4 flex flex-col justify-center">
|
||||
{currentLeaderboard.length === 0 ? (
|
||||
<div className="text-center py-8 text-zinc-450 font-medium">
|
||||
<div className="text-center py-8 text-slate-400 font-medium">
|
||||
No active nodes in this region.
|
||||
</div>
|
||||
) : (
|
||||
@@ -825,34 +684,41 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
let badgeContent: React.ReactNode = node.rank;
|
||||
|
||||
if (index === 0) {
|
||||
badgeStyle = 'bg-gradient-to-tr from-amber-300 via-yellow-400 to-amber-500 text-amber-955 border-amber-200 shadow-md shadow-amber-500/20';
|
||||
badgeStyle = 'bg-gradient-to-tr from-amber-300 via-yellow-400 to-amber-500 text-amber-950 shadow-md shadow-amber-500/20';
|
||||
badgeContent = '🥇';
|
||||
} else if (index === 1) {
|
||||
badgeStyle = 'bg-gradient-to-tr from-slate-200 via-zinc-300 to-slate-400 text-zinc-800 border-zinc-100 shadow-md shadow-zinc-400/10';
|
||||
badgeStyle = 'bg-gradient-to-tr from-slate-200 via-slate-300 to-slate-400 text-slate-800 shadow-md shadow-slate-400/10';
|
||||
badgeContent = '🥈';
|
||||
} else if (index === 2) {
|
||||
badgeStyle = 'bg-gradient-to-tr from-amber-600 via-amber-700 to-orange-850 text-amber-50 border-amber-500 shadow-md shadow-amber-800/20';
|
||||
badgeStyle = 'bg-gradient-to-tr from-amber-600 via-amber-700 to-orange-800 text-white shadow-md shadow-amber-800/20';
|
||||
badgeContent = '🥉';
|
||||
} else {
|
||||
badgeStyle = 'bg-slate-100 text-zinc-505 border-slate-200';
|
||||
badgeStyle = 'bg-slate-100 text-slate-500 border border-slate-200';
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={node.rank} className="flex items-center gap-md text-xs hover:bg-slate-50/50 p-2.5 rounded-xl transition-all duration-200 group">
|
||||
<div key={node.rank} className="flex items-center gap-4 text-xs hover:bg-slate-50 p-2.5 rounded-xl transition-all duration-200 group">
|
||||
{/* Ranking circle avatar */}
|
||||
<div className={`w-9 h-9 rounded-full border font-black flex items-center justify-center font-mono shrink-0 select-none text-sm transition-all duration-300 group-hover:scale-105 ${badgeStyle}`}>
|
||||
<div className={`w-9 h-9 rounded-full font-black flex items-center justify-center font-mono shrink-0 select-none text-sm transition-transform duration-300 group-hover:scale-105 ${badgeStyle}`}>
|
||||
{badgeContent}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex justify-between items-center font-medium mb-1">
|
||||
<span className="text-[#0f172a] font-bold truncate max-w-[12rem]">{node.name}</span>
|
||||
<span className="text-[#662582] font-mono font-bold">{node.revenue}</span>
|
||||
<div className="flex justify-between items-center font-medium mb-1.5">
|
||||
<span className="text-slate-900 font-bold truncate max-w-[12rem] flex items-center gap-1.5">
|
||||
{node.name}
|
||||
{index < 3 && (
|
||||
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[8px] font-black uppercase tracking-widest bg-emerald-50 text-emerald-600 border border-emerald-100" title="Verified Top Performer">
|
||||
<CheckCircle2 size={10} /> Verified
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-purple-700 font-mono font-bold">{node.revenue}</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-slate-100 h-2 rounded-full overflow-hidden">
|
||||
<div className="w-full bg-slate-100 h-1.5 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="bg-gradient-to-r from-purple-500 via-indigo-600 to-[#662582] h-full rounded-full transition-all duration-500 ease-out origin-left group-hover:brightness-110"
|
||||
className="bg-gradient-to-r from-purple-500 to-purple-700 h-full rounded-full transition-all duration-500 ease-out origin-left"
|
||||
style={{ width: `${node.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -997,13 +863,6 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
{filter}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={() => startExportSim('CSV')}
|
||||
className="px-3 py-1.5 bg-[#0f172a] text-white rounded-lg cursor-pointer hover:bg-zinc-805 border border-[#0f172a] transition-colors shadow-sm"
|
||||
>
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1016,15 +875,14 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
<th className="px-md py-sm">Product Name</th>
|
||||
<th className="px-md py-sm">SKU ID</th>
|
||||
<th className="px-md py-sm text-right">Units Sold</th>
|
||||
<th className="px-md py-sm text-right">Revenue</th>
|
||||
<th className="px-md py-sm text-right">Overall Revenue</th>
|
||||
<th className="px-md py-sm text-right">Stock Status</th>
|
||||
<th className="px-md py-sm text-center">Trend index</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#f1f5f9]">
|
||||
{paginatedProducts.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center py-8 text-zinc-400 font-medium">
|
||||
<td colSpan={6} className="text-center py-8 text-zinc-400 font-medium">
|
||||
No matching items matching stock filter criteria.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1076,33 +934,88 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
{prod.stockStatus}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-md py-md text-center">
|
||||
<span className="inline-block p-1 bg-[#f8fafc] border border-zinc-100/85 rounded-full select-none">
|
||||
{prod.trend === 'up' ? (
|
||||
<TrendUp size={13} className="text-emerald-505" />
|
||||
) : prod.trend === 'down' ? (
|
||||
<TrendDown size={13} className="text-rose-505" />
|
||||
) : (
|
||||
<TrendFlat size={13} className="text-zinc-400" />
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Expanded details row */}
|
||||
{isExpanded && (
|
||||
<tr className="bg-slate-50/20">
|
||||
<td colSpan={7} className="p-0 border-b border-[#e2e8f0]">
|
||||
<div className="px-lg py-md bg-gradient-to-r from-slate-50/50 to-purple-50/10 border-t border-[#e2e8f0] animate-in slide-in-from-top-2 duration-300">
|
||||
{/* Per-product stock & location breakdown has no live
|
||||
API ([R3]); the previously fabricated unit counts,
|
||||
hub split, bin code, audit date and barcode are
|
||||
replaced with the awaiting-backend placeholder. */}
|
||||
<AwaitingApi label="Per-product stock & location detail" api="[R3]" compact />
|
||||
{isExpanded && (() => {
|
||||
const storeBreakdown = activeRegionStock.map(store => {
|
||||
if (!store.rows) return null;
|
||||
const rawProdId = prod.sku.replace('SKU-', '');
|
||||
const row = store.rows.find(r =>
|
||||
r.productid?.toString() === rawProdId ||
|
||||
r.productname === prod.name
|
||||
);
|
||||
if (!row) return null;
|
||||
|
||||
const closing = Number(row.closing || 0);
|
||||
const debit = Math.max(0, Number(row.debit || 0));
|
||||
const retail = Number(row.retailprice) || Number(row.productcost) || 0;
|
||||
const rev = debit * retail;
|
||||
|
||||
let status = 'Healthy';
|
||||
if (closing <= 0) status = 'Critical';
|
||||
else if (closing < 25) status = 'Critical';
|
||||
else if (closing < 120) status = 'Low Stock';
|
||||
|
||||
return {
|
||||
locationname: store.locationname || `Store ${store.locationid}`,
|
||||
closing,
|
||||
debit,
|
||||
revenue: rev,
|
||||
status
|
||||
};
|
||||
}).filter(Boolean);
|
||||
|
||||
return (
|
||||
<tr className="bg-slate-50/50 border-b border-[#e2e8f0]">
|
||||
<td colSpan={6} className="p-0">
|
||||
<div className="px-8 py-6 bg-white border-y border-slate-100 shadow-[inset_0_4px_6px_-4px_rgba(0,0,0,0.05)] animate-in slide-in-from-top-2 duration-300">
|
||||
<h4 className="text-xs font-bold text-slate-800 mb-4 flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-purple-600 animate-pulse"></div>
|
||||
Store-by-Store Breakdown
|
||||
</h4>
|
||||
|
||||
{storeBreakdown.length === 0 ? (
|
||||
<div className="text-center py-6 text-slate-400 text-xs font-medium">No store data available for this product.</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{storeBreakdown.map((b: any, idx) => (
|
||||
<div key={idx} className="bg-white border border-slate-200/80 rounded-xl p-4 shadow-sm hover:shadow-md transition-shadow hover:border-purple-200 group">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div className="font-bold text-slate-800 text-xs group-hover:text-purple-700 transition-colors">{b.locationname}</div>
|
||||
<span className={`px-2 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wide ${
|
||||
b.status === 'Healthy' ? 'bg-emerald-50 text-emerald-600 border border-emerald-100' :
|
||||
b.status === 'Low Stock' ? 'bg-amber-50 text-amber-600 border border-amber-100' :
|
||||
'bg-rose-50 text-rose-600 border border-rose-100'
|
||||
}`}>
|
||||
{b.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[11px]">
|
||||
<div>
|
||||
<div className="text-slate-400 font-semibold mb-0.5 uppercase tracking-wider text-[9px]">In Stock</div>
|
||||
<div className="font-mono font-bold text-slate-700">{b.closing.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="w-px h-6 bg-slate-100"></div>
|
||||
<div>
|
||||
<div className="text-slate-400 font-semibold mb-0.5 uppercase tracking-wider text-[9px]">Units Sold</div>
|
||||
<div className="font-mono font-bold text-purple-600">{b.debit.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="w-px h-6 bg-slate-100"></div>
|
||||
<div className="text-right">
|
||||
<div className="text-slate-400 font-semibold mb-0.5 uppercase tracking-wider text-[9px]">Revenue</div>
|
||||
<div className="font-mono font-bold text-emerald-600">₹{b.revenue.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -213,10 +213,7 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
|
||||
<div className="space-y-lg animate-in fade-in duration-300 relative font-sans text-slate-700">
|
||||
{/* Header */}
|
||||
<div className="pb-4 border-b border-slate-100">
|
||||
<h1 className="font-sans font-bold text-3xl tracking-tight text-[#0f172a]">Settings</h1>
|
||||
<p className="text-slate-500 text-sm mt-2">
|
||||
Manage your store profile, outlet hubs, delivery configurations, payments, and team preferences.
|
||||
</p>
|
||||
|
||||
<div className="mt-3">
|
||||
{tenantsQ.isLoading ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-bold text-slate-400 uppercase tracking-wider">
|
||||
@@ -294,24 +291,12 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
|
||||
<h2 className="text-xl font-bold text-slate-900 mt-1">Identity & Contacts</h2>
|
||||
</div>
|
||||
|
||||
{/* Logo / Identity Row */}
|
||||
<div className="flex flex-col sm:flex-row gap-6 items-start sm:items-center pb-6 border-b border-slate-100">
|
||||
{/* Visual Drag and Drop Logo Uploader Placeholder */}
|
||||
<div className="w-28 h-28 rounded-2xl border-2 border-dashed border-slate-200 hover:border-purple-500 bg-slate-50/40 hover:bg-purple-50/10 flex flex-col items-center justify-center text-center cursor-pointer transition-all duration-300 relative group shrink-0 select-none shadow-sm animate-in zoom-in-95">
|
||||
<div className="p-3 rounded-xl bg-slate-100 group-hover:bg-purple-100 text-slate-400 group-hover:text-purple-600 transition-all duration-300">
|
||||
<Building2 size={24} />
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 font-extrabold mt-2 tracking-wider uppercase group-hover:text-purple-700 transition-colors">Upload Logo</span>
|
||||
<div className="absolute inset-0 bg-slate-950/85 text-white text-[10px] font-bold flex flex-col items-center justify-center opacity-0 group-hover:opacity-100 transition-all duration-300 rounded-2xl gap-1">
|
||||
<Plus size={18} />
|
||||
<span>Drop Image</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Identity Row */}
|
||||
<div className="flex flex-col pb-6 border-b border-slate-100">
|
||||
<div className="min-w-0 flex-1 leading-relaxed">
|
||||
<span className="text-base font-bold text-slate-900 mr-2">{user?.name || fstr(tenant?.tenantname) || 'Nearle Store'}</span>
|
||||
<span className="text-slate-500 text-xs font-medium">
|
||||
— Set up your store logo, customer service email, and contact number. Official registration details are synced with your primary credentials.
|
||||
— View your customer service email and contact number. Official registration details are synced with your primary credentials.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,6 @@ export default function Sidebar({
|
||||
// Navigation elements
|
||||
const navItems = [
|
||||
{ id: 'dashboard' as MainSection, label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ id: 'stores' as MainSection, label: 'Stores', icon: Store },
|
||||
{ id: 'inventory' as MainSection, label: 'Products', icon: Layers },
|
||||
{ id: 'reports' as MainSection, label: 'Reports', icon: TrendingUp },
|
||||
{ id: 'settings' as MainSection, label: 'Settings', icon: Settings }
|
||||
|
||||
@@ -20,12 +20,14 @@
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Search, Boxes, Layers, Plus, Minus, Check, CheckCircle2, X, Store, PackageSearch } from 'lucide-react';
|
||||
import { Search, Boxes, Layers, Plus, Minus, Check, CheckCircle2, X, Store, PackageSearch, Activity } from 'lucide-react';
|
||||
import { useFiestaStockStatement, FIESTA_TENANT_ID } from '../services/fiestaQueries';
|
||||
import { num as fnum, str as fstr, type Row } from '../services/fiestaApi';
|
||||
import { categoryName } from '../services/fiestaMappers';
|
||||
import { useStoreCatalogue } from '../services/storeCatalogue';
|
||||
import AwaitingApi from './AwaitingApi';
|
||||
import { SlideDrawer } from './consoleUi';
|
||||
import { useCompare } from '../contexts/CompareContext';
|
||||
|
||||
const PLACEHOLDER = 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200';
|
||||
|
||||
@@ -52,10 +54,13 @@ function catBadgeClass(category: string): string {
|
||||
}
|
||||
|
||||
export default function StoreCatalogView({ locationid, storeName = 'your store', tenantId = FIESTA_TENANT_ID }: StoreCatalogViewProps) {
|
||||
const { selectedProducts, toggleProduct, setIsComparing } = useCompare();
|
||||
const tenantid = tenantId;
|
||||
const [view, setView] = useState<'catalogue' | 'inventory'>('catalogue');
|
||||
const [search, setSearch] = useState('');
|
||||
const [category, setCategory] = useState('ALL');
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||
const [stockHealthFilter, setStockHealthFilter] = useState<string[]>([]);
|
||||
const [selectedProduct, setSelectedProduct] = useState<any>(null);
|
||||
const [notice, setNotice] = useState(false);
|
||||
|
||||
// The admin-curated catalogue (what the user is allowed to pick from).
|
||||
@@ -107,7 +112,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
const inventory = useMemo(
|
||||
() =>
|
||||
(stockQ.data ?? []).map((r: Row) => {
|
||||
const closing = fnum(r.closing);
|
||||
const closing = fnum(r.closing) ?? 0;
|
||||
return {
|
||||
id: fstr(r.productid),
|
||||
name: fstr(r.productname) || 'Unnamed product',
|
||||
@@ -126,15 +131,33 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
return inventory.filter((it) => it.name.toLowerCase().includes(term) || it.category.toLowerCase().includes(term) || it.id.toLowerCase().includes(term));
|
||||
}, [inventory, search]);
|
||||
|
||||
const categories = useMemo(() => [...new Set(products.map((p) => p.category))].sort(), [products]);
|
||||
const categories = useMemo(() => [...new Set(products.map((p) => p.category.split(' / ')[0]))].sort(), [products]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const term = search.toLowerCase();
|
||||
return products.filter((p) => {
|
||||
if (category !== 'ALL' && p.category !== category) return false;
|
||||
const pCat = p.category.split(' / ')[0];
|
||||
if (selectedCategories.length > 0 && !selectedCategories.includes(pCat)) return false;
|
||||
if (!term) return true;
|
||||
return p.name.toLowerCase().includes(term) || p.category.toLowerCase().includes(term) || p.id.toLowerCase().includes(term);
|
||||
});
|
||||
}, [products, search, category]);
|
||||
}, [products, search, selectedCategories]);
|
||||
|
||||
const toggleCategory = (cat: string) => {
|
||||
setSelectedCategories(prev => prev.includes(cat) ? prev.filter(c => c !== cat) : [...prev, cat]);
|
||||
};
|
||||
|
||||
const toggleStockHealth = (health: string) => {
|
||||
setStockHealthFilter(prev => prev.includes(health) ? prev.filter(h => h !== health) : [...prev, health]);
|
||||
};
|
||||
|
||||
const finalFilteredInventory = useMemo(() => {
|
||||
return filteredInventory.filter(it => {
|
||||
if (selectedCategories.length > 0 && !selectedCategories.includes(it.category.split(' / ')[0])) return false;
|
||||
if (stockHealthFilter.length > 0 && !stockHealthFilter.includes(it.label)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [filteredInventory, selectedCategories, stockHealthFilter]);
|
||||
|
||||
// ── Integration point ──────────────────────────────────────────────────────────
|
||||
// Replace with the real request/stock POST (selected productids + quantities),
|
||||
@@ -143,13 +166,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
|
||||
return (
|
||||
<div className="space-y-lg animate-in fade-in duration-300 font-sans pb-28">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a]">Product Catalogue</h1>
|
||||
<p className="text-zinc-500 text-xs mt-1">
|
||||
Products your admin published for <span className="font-semibold text-[#662582]">{storeName}</span> — choose what you need and set quantities.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1 bg-zinc-100/80 p-1 rounded-xl border border-zinc-200/60 w-full sm:w-auto sm:inline-flex">
|
||||
@@ -171,37 +188,122 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-md bg-white border border-[#eceef2] p-md rounded-2xl shadow-sm">
|
||||
<div className="relative w-full md:w-80 md:shrink-0">
|
||||
<div className="flex flex-col md:flex-row gap-6 items-start mt-6">
|
||||
{/* Sticky Sidebar Filter */}
|
||||
<div className="w-full md:w-64 shrink-0 bg-white border border-slate-200 rounded-2xl p-5 sticky top-24 shadow-[0_4px_20px_-4px_rgba(0,0,0,0.05)] z-10 hidden md:flex flex-col gap-6">
|
||||
<div>
|
||||
<h3 className="text-[11px] font-extrabold text-slate-400 uppercase tracking-widest mb-3 flex items-center gap-1.5">
|
||||
<Search size={14} className="text-[#662582]" /> Search
|
||||
</h3>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Product name or SKU..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-3 pr-8 py-2.5 border border-[#e2e8f0] rounded-xl text-xs font-semibold outline-none bg-slate-50 focus:bg-white focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500 transition-all shadow-sm"
|
||||
/>
|
||||
{search && (
|
||||
<button onClick={() => setSearch('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 cursor-pointer">
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full h-px bg-slate-100" />
|
||||
|
||||
<div>
|
||||
<h3 className="text-[11px] font-extrabold text-slate-400 uppercase tracking-widest mb-3 flex items-center gap-1.5">
|
||||
<Layers size={14} className="text-[#662582]" /> Categories
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{categories.map((c) => (
|
||||
<label key={c} className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCategories.includes(c)}
|
||||
onChange={() => toggleCategory(c)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className={`w-4 h-4 rounded-[4px] border flex items-center justify-center transition-colors ${selectedCategories.includes(c) ? 'bg-[#662582] border-[#662582]' : 'bg-slate-50 border-slate-300 group-hover:border-purple-400'}`}>
|
||||
{selectedCategories.includes(c) && <Check size={10} className="text-white" strokeWidth={3} />}
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-slate-700 group-hover:text-slate-900">{c}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view === 'inventory' && (
|
||||
<>
|
||||
<div className="w-full h-px bg-slate-100" />
|
||||
<div>
|
||||
<h3 className="text-[11px] font-extrabold text-slate-400 uppercase tracking-widest mb-3 flex items-center gap-1.5">
|
||||
<Activity size={14} className="text-[#662582]" /> Stock Health
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{['Healthy', 'Low', 'Critical', 'Out of stock'].map((h) => (
|
||||
<label key={h} className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stockHealthFilter.includes(h)}
|
||||
onChange={() => toggleStockHealth(h)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className={`w-4 h-4 rounded-[4px] border flex items-center justify-center transition-colors ${stockHealthFilter.includes(h) ? 'bg-[#662582] border-[#662582]' : 'bg-slate-50 border-slate-300 group-hover:border-purple-400'}`}>
|
||||
{stockHealthFilter.includes(h) && <Check size={10} className="text-white" strokeWidth={3} />}
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-slate-700 group-hover:text-slate-900">{h}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(selectedCategories.length > 0 || stockHealthFilter.length > 0 || search) && (
|
||||
<button
|
||||
onClick={() => { setSelectedCategories([]); setStockHealthFilter([]); setSearch(''); }}
|
||||
className="mt-2 w-full py-2 bg-slate-100 text-slate-600 rounded-xl text-[10px] font-bold uppercase tracking-wider hover:bg-slate-200 transition-colors cursor-pointer"
|
||||
>
|
||||
Clear All Filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Product Grid Area */}
|
||||
<div className="flex-1 min-w-0 max-h-[850px] overflow-y-auto custom-scrollbar pr-4">
|
||||
{/* Mobile Search Input (Visible only on mobile) */}
|
||||
<div className="md:hidden mb-4">
|
||||
<div className="relative">
|
||||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={view === 'catalogue' ? 'Search catalogue products…' : 'Search your stock…'}
|
||||
placeholder={view === 'catalogue' ? 'Search catalogue...' : 'Search your stock...'}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-9 pr-9 py-2.5 border border-[#e2e8f0] rounded-xl text-xs outline-none bg-[#f8fafc] focus:bg-white focus:ring-1 focus:ring-[#662582] transition-all"
|
||||
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-zinc-400 hover:text-zinc-600"><X size={13} /></button>
|
||||
<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>
|
||||
{view === 'catalogue' && categories.length > 0 && (
|
||||
<div className="flex items-center gap-sm flex-wrap">
|
||||
<span className="flex items-center gap-1.5 text-[10px] font-bold text-zinc-400 uppercase tracking-widest"><Layers size={13} className="text-[#662582]" /> Filter</span>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="border border-[#e2e8f0] rounded-lg px-3 py-2 text-xs font-semibold text-zinc-700 bg-[#f8fafc] outline-none focus:ring-1 focus:ring-[#662582] cursor-pointer"
|
||||
>
|
||||
<option value="ALL">All categories</option>
|
||||
{categories.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="md:ml-auto text-[11px] font-semibold text-zinc-400">
|
||||
{view === 'catalogue' ? `${filtered.length} products` : `${inventory.length} stocked`}
|
||||
|
||||
{/* Results Summary & Toolbar */}
|
||||
<div className="flex justify-between items-end mb-4 px-1">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-slate-900">
|
||||
{view === 'catalogue' ? 'Global Catalogue' : 'My Inventory'}
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
Showing {view === 'catalogue' ? filtered.length : finalFilteredInventory.length} results
|
||||
</p>
|
||||
</div>
|
||||
{/* Can add sorting dropdown here if needed */}
|
||||
</div>
|
||||
|
||||
{/* ── Browse Catalogue ── */}
|
||||
@@ -217,87 +319,108 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
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">
|
||||
<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} 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" />
|
||||
<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 && (
|
||||
<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 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>
|
||||
<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)}`}>
|
||||
<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 className="flex justify-between items-center pt-2">
|
||||
</div>
|
||||
|
||||
{/* Details Bento Block */}
|
||||
<div className="p-3.5 flex flex-col flex-1 gap-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<span className="text-[8px] text-zinc-400 block uppercase tracking-wider font-extrabold">Price</span>
|
||||
<span className="font-extrabold text-zinc-700 font-mono text-xs">{p.price > 0 ? `₹${p.price.toLocaleString('en-IN')}` : '—'}</span>
|
||||
</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>
|
||||
|
||||
{/* 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 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>
|
||||
|
||||
{/* Pick action: quantity stepper when selected, else add button */}
|
||||
{/* Action Bento Block */}
|
||||
<div className="pt-1">
|
||||
{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 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={() => 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"
|
||||
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={13} /> Add to Store
|
||||
<Plus size={14} /> Add to Store
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* ── My Store Inventory ── (card grid — same design as Browse Catalogue) */}
|
||||
{/* ── 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." />
|
||||
@@ -305,97 +428,177 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
<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 ? (
|
||||
) : finalFilteredInventory.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."
|
||||
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('')} 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 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-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 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="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)}`}>
|
||||
<div className="absolute top-3 left-3 z-10 flex items-center gap-2">
|
||||
<span className={`px-2 py-1 rounded-md text-[9px] font-black uppercase shadow-sm tracking-wider ${catBadgeClass(it.category)}`}>
|
||||
{it.category.split(' / ')[0]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pt-2">
|
||||
</div>
|
||||
|
||||
{/* Details Bento Block */}
|
||||
<div className="p-3.5 flex flex-col flex-1 gap-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<span className="text-[8px] text-zinc-400 block uppercase tracking-wider font-extrabold">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>
|
||||
<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>
|
||||
|
||||
{/* 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 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>
|
||||
|
||||
{/* ── Selection action bar ── */}
|
||||
{/* ── Selection FAB ── */}
|
||||
{view === 'catalogue' && pickCount > 0 && (
|
||||
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-[120] w-[min(680px,calc(100vw-2rem))]">
|
||||
<div className="bg-[#0f172a] text-white rounded-2xl shadow-2xl border border-white/10 px-4 py-3">
|
||||
<div className="fixed bottom-6 right-6 z-[120]">
|
||||
{notice ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold">{pickCount} product{pickCount > 1 ? 's' : ''} requested for {storeName}</span>
|
||||
<div className="bg-[#0f172a] text-white rounded-2xl shadow-2xl border border-white/10 px-5 py-4 w-80 animate-in slide-in-from-bottom-4 duration-300">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs font-bold">{pickCount} product{pickCount > 1 ? 's' : ''} requested</span>
|
||||
<button onClick={() => { setPicks({}); setNotice(false); }} className="text-[11px] font-semibold text-purple-200 hover:text-white cursor-pointer">Clear</button>
|
||||
</div>
|
||||
<AwaitingApi label="Submitting your store request" api="stock-request API" compact className="bg-white/5 border-white/15 text-purple-100" />
|
||||
<AwaitingApi label="Submitting to store" api="stock-request API" compact className="bg-white/5 border-white/15 text-purple-100" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center shrink-0"><Boxes size={15} /></span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-bold truncate">{pickCount} product{pickCount > 1 ? 's' : ''} · {Object.values(picks).reduce((a: number, b: number) => a + b, 0)} units</p>
|
||||
<p className="text-[10px] text-purple-200">Selected for {storeName}</p>
|
||||
<button
|
||||
onClick={commitSelectionToStore}
|
||||
className="flex items-center gap-3 bg-gradient-to-r from-purple-700 to-[#662582] text-white px-6 py-4 rounded-full shadow-[0_8px_30px_rgba(102,37,130,0.4)] hover:shadow-[0_12px_40px_rgba(102,37,130,0.6)] hover:-translate-y-1 transition-all duration-300 cursor-pointer group"
|
||||
>
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-white/20 text-white font-bold text-sm shadow-inner group-hover:scale-110 transition-transform">
|
||||
{pickCount}
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<span className="block text-sm font-bold uppercase tracking-wider">Save Picks</span>
|
||||
<span className="block text-[10px] text-purple-200">{Object.values(picks).reduce((a: number, b: number) => a + b, 0)} items total</span>
|
||||
</div>
|
||||
<div className="ml-2 w-8 h-8 rounded-full bg-white text-purple-700 flex items-center justify-center shadow-md">
|
||||
<Check size={16} strokeWidth={3} />
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* ADD/EDIT PRODUCT MODAL (simulated) */}
|
||||
<SlideDrawer
|
||||
isOpen={!!selectedProduct}
|
||||
onClose={() => setSelectedProduct(null)}
|
||||
title={view === 'catalogue' ? 'Catalogue Product Details' : 'Store Inventory Status'}
|
||||
>
|
||||
{selectedProduct && (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="w-full h-64 bg-slate-50 rounded-2xl overflow-hidden border border-slate-100 relative">
|
||||
<img src={selectedProduct.image} alt={selectedProduct.name} className="w-full h-full object-cover" />
|
||||
<div className="absolute top-3 left-3">
|
||||
<span className={`px-2.5 py-1.5 rounded-lg text-[10px] font-black uppercase shadow-sm tracking-wider ${catBadgeClass(selectedProduct.category)}`}>
|
||||
{selectedProduct.category.split(' / ')[0]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xl font-black text-slate-900 leading-tight mb-2">{selectedProduct.name}</h3>
|
||||
<p className="text-xs font-bold text-slate-400 font-mono tracking-wider">{selectedProduct.sku}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200 flex justify-between items-center">
|
||||
<div>
|
||||
<span className="text-[10px] font-extrabold uppercase tracking-widest text-slate-400 block mb-1">Pricing</span>
|
||||
<span className="text-2xl font-black font-mono text-slate-900">
|
||||
{selectedProduct.price > 0 ? `₹${selectedProduct.price.toLocaleString('en-IN')}` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-[10px] font-extrabold uppercase tracking-widest text-slate-400 block mb-1">Unit</span>
|
||||
<span className="text-sm font-bold text-slate-600 bg-white px-3 py-1 rounded-lg border border-slate-200 shadow-sm">{selectedProduct.unit || 'Piece'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedProduct.closing !== undefined && (
|
||||
<div className="bg-purple-50 p-4 rounded-xl border border-purple-100 flex justify-between items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full flex items-center justify-center shadow-inner" style={{ backgroundColor: selectedProduct.color, color: 'white' }}>
|
||||
<Boxes size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] font-extrabold uppercase tracking-widest text-purple-400 block mb-0.5">Live Stock Level</span>
|
||||
<span className="text-sm font-black text-purple-900 flex items-center gap-1.5">
|
||||
{selectedProduct.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-2xl font-black font-mono text-purple-700">{selectedProduct.closing.toLocaleString('en-IN')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-slate-500 leading-relaxed mt-2 border-t border-slate-100 pt-5">
|
||||
This is a shared product available in the Global Catalogue. Make sure to keep adequate stock to prevent customer order cancellations due to unavailability.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SlideDrawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -416,28 +619,11 @@ function CenterState({ icon, title, sub, action }: { icon: React.ReactNode; titl
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="font-bold text-lg text-[#0f172a] tracking-tight">{title}</h3>
|
||||
{sub && <p className="text-sm text-zinc-500 mt-2 max-w-md leading-relaxed">{sub}</p>}
|
||||
<h3 className="font-bold text-lg text-[#0f172a] tracking-tight text-center">{title}</h3>
|
||||
{sub && <p className="text-sm text-zinc-500 mt-2 text-center whitespace-nowrap overflow-hidden text-ellipsis">{sub}</p>}
|
||||
|
||||
{action && <div className="mt-6">{action}</div>}
|
||||
|
||||
{/* Ghost preview cards — hint at what will appear here */}
|
||||
<div className="mt-9 flex items-end justify-center gap-3 sm:gap-4 opacity-70 select-none" aria-hidden>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-24 sm:w-28 rounded-2xl border border-[#eceef2] bg-white/70 p-3 shadow-sm ${i === 1 ? 'scale-110' : 'opacity-80'}`}
|
||||
>
|
||||
<div className="w-full h-10 rounded-lg bg-gradient-to-br from-zinc-100 to-zinc-200/70 mb-2" />
|
||||
<div className="h-2 w-3/4 rounded-full bg-zinc-200 mb-1.5" />
|
||||
<div className="h-2 w-1/2 rounded-full bg-zinc-100" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 inline-flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-zinc-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" /> Syncs automatically
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -389,7 +389,7 @@ export default function StoreDetailView({ store, onBack, canManage = true, only,
|
||||
className="flex items-center gap-xs text-xs font-bold text-[#662582] hover:text-[#4c1d95] bg-purple-50 hover:bg-purple-100/80 px-xl py-2 rounded-lg transition-all shadow-sm border border-purple-100 cursor-pointer"
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
<span>Back to Registry</span>
|
||||
<span>Back to Dashboard</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
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 StoreQRView from './StoreQRView';
|
||||
import UserStoreSidebar, { type UserNavItem } from './UserStoreSidebar';
|
||||
import ComparisonModal from './ComparisonModal';
|
||||
|
||||
interface UserStorePageProps {
|
||||
/** Returns to the login screen. */
|
||||
@@ -352,6 +353,8 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
<ComparisonModal />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
Bike,
|
||||
Clock
|
||||
} from 'lucide-react';
|
||||
import { useFiestaUsers, useFiestaCreateUser, useFiestaRiderShifts, useFiestaTenantLocations } from '../services/fiestaQueries';
|
||||
import { useFiestaUsers, useFiestaCreateUser, useFiestaUpdateUser, useFiestaRiderShifts, useFiestaTenantLocations } from '../services/fiestaQueries';
|
||||
import { useAppRoles } from '../services/queries';
|
||||
import { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../services/fiestaApi';
|
||||
import AddressAutocomplete, { type AddressResult } from './AddressAutocomplete';
|
||||
@@ -76,8 +76,30 @@ const FALLBACK_ROLE_CHOICES = [
|
||||
export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUserRole = 4 }: UsersPanelProps) {
|
||||
const usersQ = useFiestaUsers({ tenantid: tenantId, pagesize: 100 });
|
||||
const createUserMut = useFiestaCreateUser();
|
||||
const updateUserMut = useFiestaUpdateUser();
|
||||
const rolesQ = useAppRoles();
|
||||
|
||||
const [assignUserId, setAssignUserId] = useState<number | null>(null);
|
||||
const [assignLocationId, setAssignLocationId] = useState<number>(0);
|
||||
const [assignRoleId, setAssignRoleId] = useState<number>(4);
|
||||
|
||||
const handleToggleAssign = async (userid: number, currentRoleId: number) => {
|
||||
try {
|
||||
if (currentRoleId <= 0) {
|
||||
// Open the assign modal
|
||||
setAssignUserId(userid);
|
||||
if (storeOptions.length === 1) {
|
||||
setAssignLocationId(storeOptions[0].locationid);
|
||||
}
|
||||
} else {
|
||||
// Unassign immediately. We use -1 because the Go backend ignores 0 (zero value) during updates.
|
||||
await updateUserMut.mutateAsync({ userid, roleid: -1, locationid: -1, applocationid: -1 });
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Could not update user role: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Selectable roles for the Add User modal — driven by the live app-roles API,
|
||||
// matched to local icon/desc styling by roleid; falls back to the static list.
|
||||
// Selectable roles for the Add User modal - limited to Staff and Rider.
|
||||
@@ -130,7 +152,10 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
|
||||
if (!newUser.locationid && storeOptions.length === 1) {
|
||||
setNewUser((u) => ({ ...u, locationid: storeOptions[0].locationid, applocationid: storeOptions[0].applocationid }));
|
||||
}
|
||||
}, [storeOptions.length, newUser.locationid]);
|
||||
if (!assignLocationId && storeOptions.length === 1) {
|
||||
setAssignLocationId(storeOptions[0].locationid);
|
||||
}
|
||||
}, [storeOptions.length, newUser.locationid, assignLocationId]);
|
||||
|
||||
// Rider-shift picker — shown only when a rider role is selected (parity with the
|
||||
// merchant_web create form). Shifts come from the live partners/getridershifts.
|
||||
@@ -189,6 +214,7 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
|
||||
|
||||
const roleOptions = React.useMemo(() => {
|
||||
const roles = new Set(users.map((u) => u.role));
|
||||
roles.add('Admin');
|
||||
roles.add('Rider');
|
||||
return Array.from(roles);
|
||||
}, [users]);
|
||||
@@ -280,25 +306,25 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
|
||||
</div>
|
||||
|
||||
{/* Search & Filter Utility Bar */}
|
||||
<div className="bg-white border border-slate-200/80 p-3 rounded-2xl flex flex-col md:flex-row gap-4 items-stretch md:items-center justify-between shadow-sm">
|
||||
<div className="relative w-full md:max-w-md shrink-0">
|
||||
<div className="relative flex w-full items-center bg-slate-50 border border-slate-200/80 rounded-xl transition-all duration-300 focus-within:bg-white focus-within:ring-4 focus-within:ring-purple-500/10 focus-within:border-purple-500 group-search shadow-inner">
|
||||
<span className="pl-4 flex items-center pointer-events-none text-slate-400 transition-colors">
|
||||
<Search size={18} strokeWidth={2.5} />
|
||||
<div className="flex flex-col md:flex-row gap-6 items-stretch md:items-center justify-between mb-4">
|
||||
<div className="relative w-full md:max-w-sm shrink-0">
|
||||
<div className="relative flex w-full items-center border-b-2 border-slate-200 focus-within:border-purple-600 transition-colors duration-300">
|
||||
<span className="pl-1 flex items-center pointer-events-none text-slate-400">
|
||||
<Search size={16} strokeWidth={3} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search team members..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-3 pr-12 py-3 bg-transparent border-none text-sm font-semibold text-slate-800 placeholder-slate-400 focus:outline-none outline-none"
|
||||
className="w-full pl-3 pr-10 py-2.5 bg-transparent border-none text-sm font-semibold text-slate-800 placeholder-slate-400 focus:outline-none outline-none"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-700 hover:bg-slate-200/50 active:scale-95 transition-all p-1.5 rounded-lg flex items-center justify-center"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-700 active:scale-95 transition-all p-1.5 flex items-center justify-center"
|
||||
>
|
||||
<X size={16} strokeWidth={2.5} />
|
||||
<X size={14} strokeWidth={3} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -333,7 +359,7 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
|
||||
</div>
|
||||
|
||||
{/* Directory Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-md">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{filteredUsers.length === 0 ? (
|
||||
<div className="col-span-full bg-white border border-slate-200/60 rounded-2xl p-12 text-center text-slate-500 font-bold text-base">
|
||||
{usersQ.isLoading ? 'Loading team list…' : 'No matches found in team list.'}
|
||||
@@ -342,62 +368,77 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
|
||||
filteredUsers.map((u) => {
|
||||
const roleInfo = ROLE_THEMES[u.roleid] || { bg: 'bg-slate-55', text: 'text-slate-700', border: 'border-slate-100', label: u.role };
|
||||
return (
|
||||
<div key={u.userid} className="bg-white border border-slate-200/60 hover:border-purple-300 hover:shadow-md rounded-2xl p-5 transition-all duration-300 flex flex-col justify-between group relative overflow-hidden">
|
||||
<div key={u.userid} className="bg-white border border-slate-200/60 hover:border-purple-300 hover:shadow-md rounded-2xl p-4 transition-all duration-300 flex flex-col justify-between group relative overflow-hidden">
|
||||
{/* Background aura gradient effect on hover */}
|
||||
<div className="absolute top-0 right-0 w-28 h-28 bg-purple-500/5 rounded-full blur-xl -mr-8 -mt-8 pointer-events-none transition-all group-hover:bg-purple-500/10" />
|
||||
<div className="absolute top-0 right-0 w-24 h-24 bg-purple-500/5 rounded-full blur-xl -mr-6 -mt-6 pointer-events-none transition-all group-hover:bg-purple-500/10" />
|
||||
|
||||
<div>
|
||||
<div className="flex items-start gap-4 relative z-10">
|
||||
<div className="flex items-start gap-3 relative z-10">
|
||||
{/* User Avatar with status indicator ring */}
|
||||
<div className="relative shrink-0 select-none">
|
||||
<img
|
||||
src={u.avatar}
|
||||
alt={u.name}
|
||||
referrerPolicy="no-referrer"
|
||||
className="w-14 h-14 object-cover rounded-2xl border border-slate-200"
|
||||
className="w-10 h-10 object-cover rounded-[10px] border border-slate-200"
|
||||
/>
|
||||
<span className={`absolute -bottom-1 -right-1 w-4 h-4 rounded-full border-3 border-white ${
|
||||
<span className={`absolute -bottom-1 -right-1 w-3.5 h-3.5 rounded-full border-[2.5px] border-white ${
|
||||
u.status.toLowerCase() === 'active' ? 'bg-emerald-500' : 'bg-slate-350'
|
||||
}`} />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="font-bold text-slate-800 text-sm truncate group-hover:text-purple-950 transition-colors leading-tight">{u.name}</h4>
|
||||
<p className="text-xs text-slate-450 mt-1.5 truncate font-medium">{u.email}</p>
|
||||
<p className="text-[11px] text-slate-450 mt-1 truncate font-medium">{u.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata fields */}
|
||||
<div className="mt-5 space-y-2.5 border-t border-slate-100/75 pt-4 text-xs">
|
||||
<div className="flex items-center gap-2.5 text-slate-600 font-medium">
|
||||
<Phone size={14} className="text-slate-400 shrink-0" />
|
||||
<div className="mt-4 space-y-2 border-t border-slate-100/75 pt-3 text-[11px]">
|
||||
<div className="flex items-center gap-2 text-slate-600 font-medium">
|
||||
<Phone size={13} className="text-slate-400 shrink-0" />
|
||||
<span className="font-mono">{u.contact}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 text-slate-600 font-medium">
|
||||
<MapPin size={14} className="text-slate-400 shrink-0" />
|
||||
<div className="flex items-center gap-2 text-slate-600 font-medium">
|
||||
<MapPin size={13} className="text-slate-400 shrink-0" />
|
||||
<span className="truncate">{u.location}</span>
|
||||
</div>
|
||||
{u.shift && u.shift !== '—' && (
|
||||
<div className="flex items-center gap-2.5 text-slate-600 font-medium">
|
||||
<span className="text-[10px] uppercase tracking-wider text-slate-400 font-extrabold">Shift</span>
|
||||
<span className="font-bold text-slate-700 bg-slate-50 px-2.5 py-0.5 rounded-lg border border-slate-150 text-xs">{u.shift}</span>
|
||||
<div className="flex items-center gap-2 text-slate-600 font-medium">
|
||||
<span className="text-[9px] uppercase tracking-wider text-slate-400 font-extrabold">Shift</span>
|
||||
<span className="font-bold text-slate-700 bg-slate-50 px-2 py-0.5 rounded-md border border-slate-150">{u.shift}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 pt-4 border-t border-slate-100/75 flex justify-between items-center select-none">
|
||||
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-xl text-xs font-extrabold uppercase border ${roleInfo.bg} ${roleInfo.text} ${roleInfo.border}`}>
|
||||
{u.roleid === 1 && <ShieldAlert size={12} />}
|
||||
{u.roleid === 2 && <Shield size={12} />}
|
||||
{u.roleid === 3 && <SlidersHorizontal size={12} />}
|
||||
{u.roleid === 4 && <User size={12} />}
|
||||
{u.roleid === 5 && <Bike size={12} />}
|
||||
{u.roleid === 6 && <Coins size={12} />}
|
||||
<div className="mt-4 pt-3 border-t border-slate-100/75 flex justify-between items-center select-none">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-[8px] text-[10px] font-extrabold uppercase border ${roleInfo.bg} ${roleInfo.text} ${roleInfo.border}`}>
|
||||
{u.roleid === 1 && <ShieldAlert size={10} />}
|
||||
{u.roleid === 2 && <Shield size={10} />}
|
||||
{u.roleid === 3 && <SlidersHorizontal size={10} />}
|
||||
{u.roleid === 4 && <User size={10} />}
|
||||
{u.roleid === 5 && <Bike size={10} />}
|
||||
{u.roleid === 6 && <Coins size={10} />}
|
||||
{roleInfo.label}
|
||||
</span>
|
||||
{u.roleid !== 1 && (
|
||||
<button
|
||||
onClick={() => handleToggleAssign(u.userid, u.roleid)}
|
||||
disabled={updateUserMut.isPending}
|
||||
className={`px-2 py-0.5 rounded-[8px] text-[9px] font-extrabold uppercase transition-colors cursor-pointer border ${
|
||||
u.roleid <= 0
|
||||
? 'bg-emerald-50 text-emerald-700 border-emerald-200 hover:bg-emerald-100'
|
||||
: 'bg-slate-50 text-slate-500 border-slate-200 hover:bg-slate-100 hover:text-slate-700'
|
||||
} ${updateUserMut.isPending ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{u.roleid <= 0 ? 'Assign' : 'Unassign'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="text-xs font-mono text-slate-400 font-bold">ID: #{u.userid}</span>
|
||||
<span className="text-[10px] font-mono text-slate-400 font-bold">#{u.userid}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -615,6 +656,99 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ASSIGN USER MODAL */}
|
||||
{assignUserId !== null && (
|
||||
<div
|
||||
className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-[200] flex items-center justify-center p-md select-none"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setAssignUserId(null); }}
|
||||
>
|
||||
<div className="bg-white border border-slate-200/80 rounded-2xl w-full max-w-[28rem] flex flex-col shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200 text-sm font-sans cursor-default">
|
||||
<div className="p-5 border-b border-slate-100 bg-slate-50/50 flex justify-between items-center shrink-0">
|
||||
<h4 className="font-bold text-slate-900 flex items-center gap-2.5 text-base">
|
||||
<div className="w-8 h-8 rounded-lg bg-emerald-50 text-emerald-650 flex items-center justify-center">
|
||||
<UserCheck size={16} />
|
||||
</div>
|
||||
Assign Team Member
|
||||
</h4>
|
||||
<button
|
||||
onClick={() => setAssignUserId(null)}
|
||||
className="p-1.5 hover:bg-slate-100 rounded-lg text-slate-400 hover:text-slate-600 cursor-pointer transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const store = storeOptions.find(s => s.locationid === assignLocationId);
|
||||
try {
|
||||
await updateUserMut.mutateAsync({
|
||||
userid: assignUserId,
|
||||
roleid: assignRoleId,
|
||||
locationid: assignLocationId,
|
||||
applocationid: store?.applocationid ?? 1
|
||||
});
|
||||
setAssignUserId(null);
|
||||
} catch (err) {
|
||||
alert(`Could not assign user: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
}
|
||||
}} className="p-6 space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">ROLE (*)</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={assignRoleId}
|
||||
onChange={(e) => setAssignRoleId(Number(e.target.value))}
|
||||
className="w-full border border-slate-200 rounded-xl px-4 py-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm cursor-pointer"
|
||||
>
|
||||
{roleChoices.map((r) => (
|
||||
<option key={r.id} value={r.id}>{r.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">STORE / LOCATION (*)</label>
|
||||
<div className="relative">
|
||||
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
|
||||
<Store size={14} />
|
||||
</span>
|
||||
<select
|
||||
value={assignLocationId}
|
||||
onChange={(e) => setAssignLocationId(Number(e.target.value))}
|
||||
className="w-full border border-slate-200 rounded-xl pl-10 pr-4 py-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm cursor-pointer"
|
||||
required
|
||||
>
|
||||
<option value={0} disabled>{locationsQ.isLoading ? 'Loading stores…' : 'Select a store…'}</option>
|
||||
{storeOptions.map((s) => (
|
||||
<option key={s.locationid} value={s.locationid}>{s.name}{s.address ? ` — ${s.address}` : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-slate-100 flex justify-end gap-sm mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAssignUserId(null)}
|
||||
className="px-5 py-2.5 border border-slate-200 hover:bg-slate-100 rounded-xl font-bold text-slate-500 cursor-pointer active:scale-95 transition-all text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={updateUserMut.isPending || !assignLocationId}
|
||||
className="px-6 py-2.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl font-bold cursor-pointer shadow-sm disabled:opacity-60 disabled:cursor-not-allowed active:scale-95 transition-all flex items-center gap-1.5 border-none text-sm"
|
||||
>
|
||||
{updateUserMut.isPending ? 'Assigning…' : 'Assign User'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
* source's MUI `sx`) while layout/spacing use Tailwind.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { X, Info } from 'lucide-react';
|
||||
|
||||
// ── Design tokens ────────────────────────────────────────────────────────────────
|
||||
export const BRAND = '#662582';
|
||||
@@ -141,32 +142,28 @@ export function KpiStrip({ items, loading }: { items: KpiItem[]; loading?: boole
|
||||
<div
|
||||
key={it.label}
|
||||
onClick={it.onClick}
|
||||
className={`relative overflow-hidden rounded-2xl border bg-white p-3.5 sm:p-5 transition-all duration-200 ${it.onClick ? 'cursor-pointer hover:-translate-y-0.5' : ''}`}
|
||||
style={{ borderColor: it.active ? it.color : BORDER, boxShadow: it.active ? `0 0 0 1px ${it.color}, ${SHADOW_MD}` : 'none' }}
|
||||
onMouseEnter={(e) => { if (!it.active && it.onClick) e.currentTarget.style.boxShadow = SHADOW_MD; }}
|
||||
onMouseLeave={(e) => { if (!it.active) e.currentTarget.style.boxShadow = 'none'; }}
|
||||
className={`rounded-xl p-3 flex flex-col justify-between transition-all duration-300 text-left relative group border bg-white ${it.active ? 'border-indigo-500 opacity-100 shadow-[0_8px_24px_rgba(99,102,241,0.06)]' : 'border-slate-200/70 opacity-70 hover:opacity-100'} ${it.onClick ? 'cursor-pointer' : ''}`}
|
||||
>
|
||||
<div className="absolute top-0 left-0 right-0" style={{ height: 3, background: `linear-gradient(90deg, ${it.color} 0%, ${soft(it.color)} 100%)` }} />
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] sm:text-[11px] font-bold uppercase tracking-wide truncate" style={{ color: TEXT_2, letterSpacing: 0.4 }}>
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<span className="text-[10px] text-slate-500 uppercase tracking-widest font-bold">
|
||||
{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)}` }}
|
||||
<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 }}
|
||||
>
|
||||
{it.icon}
|
||||
</span>
|
||||
{React.cloneElement(it.icon as React.ReactElement, { className: 'w-3.5 h-3.5' })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 w-full">
|
||||
<h3 className="text-lg font-extrabold tracking-tight font-mono text-slate-900 truncate">
|
||||
{loading ? '—' : it.value}
|
||||
</h3>
|
||||
{it.badge && (
|
||||
<p className="text-[10px] text-slate-400 font-semibold mt-0.5">
|
||||
{it.badge}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -328,3 +325,91 @@ export function TableShell({ head, children, minWidth, footer }: { head: string[
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Spinny UX Components ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Beautiful Skeleton Loader for empty/loading states.
|
||||
* Uses a pulsing animation to match shape before data arrives.
|
||||
*/
|
||||
export function Skeleton({ className = '', style }: { className?: string; style?: React.CSSProperties }) {
|
||||
return (
|
||||
<div
|
||||
className={`animate-pulse bg-slate-200/60 rounded-md ${className}`}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive Tooltip (Jargon Buster).
|
||||
* Wraps children and shows a sleek dark explanation box on hover.
|
||||
*/
|
||||
export function Tooltip({ children, content, position = 'top' }: { children: React.ReactNode; content: React.ReactNode; position?: 'top' | 'bottom' }) {
|
||||
return (
|
||||
<div className="relative flex items-center group cursor-help w-max">
|
||||
{children}
|
||||
<div className={`absolute ${position === 'top' ? 'bottom-full mb-2' : 'top-full mt-2'} left-1/2 -translate-x-1/2 hidden group-hover:block w-max max-w-[250px] z-[100] animate-in fade-in zoom-in-95 duration-200`}>
|
||||
<div className="bg-[#0f172a] text-white text-[10px] font-medium p-2.5 rounded-xl shadow-xl border border-white/10 text-center leading-relaxed">
|
||||
{content}
|
||||
</div>
|
||||
<div className={`absolute ${position === 'top' ? 'top-full border-t-[#0f172a] border-x-transparent border-b-transparent' : 'bottom-full border-b-[#0f172a] border-x-transparent border-t-transparent'} left-1/2 -translate-x-1/2 border-[5px]`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Quick Look" Slide-out Drawer.
|
||||
* Slides in from the right edge of the screen.
|
||||
*/
|
||||
export function SlideDrawer({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
|
||||
// Prevent body scroll when open
|
||||
useEffect(() => {
|
||||
if (isOpen) document.body.style.overflow = 'hidden';
|
||||
else document.body.style.overflow = '';
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex justify-end">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-slate-900/30 backdrop-blur-sm animate-in fade-in duration-300"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Drawer Panel */}
|
||||
<div className="relative w-full max-w-md h-full bg-white shadow-2xl border-l border-slate-200 animate-in slide-in-from-right duration-300 ease-out flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-5 border-b border-slate-100 bg-white z-10 shrink-0">
|
||||
<h2 className="text-lg font-extrabold text-slate-900 tracking-tight">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-slate-100 text-slate-500 transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
@theme {
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-sans: "Jost", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
|
||||
|
||||
--spacing-xs: 4px;
|
||||
|
||||
@@ -3,6 +3,7 @@ import {createRoot} from 'react-dom/client';
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
import { CompareProvider } from './contexts/CompareContext.tsx';
|
||||
|
||||
// How often every page silently re-syncs with the backend. Orders/deliveries
|
||||
// statuses change out-of-band (riders accept/pick/deliver, customers place
|
||||
@@ -35,7 +36,9 @@ const queryClient = new QueryClient({
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CompareProvider>
|
||||
<App />
|
||||
</CompareProvider>
|
||||
</QueryClientProvider>
|
||||
</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). */
|
||||
export function roleName(roleid: number): string {
|
||||
const map: Record<number, string> = {
|
||||
[-1]: 'Unassigned',
|
||||
0: 'Unassigned',
|
||||
1: 'Owner',
|
||||
2: 'Manager',
|
||||
@@ -914,6 +915,9 @@ export interface UpdateUserInput {
|
||||
state?: string;
|
||||
postcode?: string;
|
||||
status?: string;
|
||||
roleid?: number | null;
|
||||
locationid?: number | null;
|
||||
applocationid?: number | null;
|
||||
}
|
||||
|
||||
/** PUT /users/update — update an existing web staff user. */
|
||||
|
||||
Reference in New Issue
Block a user