feat: relocate orders and deliveries to store console & polish store cover images
This commit is contained in:
286
src/components/DashboardView.tsx
Normal file
286
src/components/DashboardView.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
ShoppingBag,
|
||||
PackageCheck,
|
||||
Wallet,
|
||||
TrendingUp,
|
||||
Store,
|
||||
MapPin,
|
||||
Phone,
|
||||
Sparkles,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { useOrderSummary, useTenantInfo, useTenantLocations, useInvoiceInsight } from '../services/queries';
|
||||
import { DEFAULT_TENANT_ID, DEFAULT_CONFIG_ID } from '../services/api';
|
||||
import { useFiestaLocationSummary } from '../services/fiestaQueries';
|
||||
import { FIESTA_TENANT_ID } from '../services/fiestaApi';
|
||||
|
||||
interface DashboardViewProps {
|
||||
searchQuery: string;
|
||||
isCoimbatoreView: boolean;
|
||||
}
|
||||
|
||||
const ymd = (d: Date) =>
|
||||
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
|
||||
const str = (v: unknown): string => (v == null ? '' : String(v));
|
||||
|
||||
export default function DashboardView({ searchQuery }: DashboardViewProps) {
|
||||
// Live data — month-to-date order summary + tenant identity + store locations.
|
||||
const today = new Date();
|
||||
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
const fromdate = ymd(monthStart);
|
||||
const todate = ymd(today);
|
||||
|
||||
const summaryQ = useOrderSummary(DEFAULT_TENANT_ID, fromdate, todate, DEFAULT_CONFIG_ID);
|
||||
const tenantQ = useTenantInfo(DEFAULT_TENANT_ID);
|
||||
const locationsQ = useTenantLocations(DEFAULT_TENANT_ID);
|
||||
const insightQ = useInvoiceInsight(DEFAULT_TENANT_ID);
|
||||
|
||||
const s = summaryQ.data;
|
||||
const tenantName = str(tenantQ.data?.tenantname) || s?.tenantname || `Tenant ${DEFAULT_TENANT_ID}`;
|
||||
|
||||
// Profit comes from the live invoice/financial insight. When the tenant has no
|
||||
// invoice records we show "—" rather than a misleading ₹0.
|
||||
const insight = insightQ.data;
|
||||
const money = (v: number | null) => (v == null ? '—' : `₹${Math.round(v).toLocaleString('en-IN')}`);
|
||||
const todaysProfit = insight ? insight.profit : null;
|
||||
const monthlyProfit = insight ? insight.profit : null;
|
||||
|
||||
const locSummaryQ = useFiestaLocationSummary(FIESTA_TENANT_ID);
|
||||
const summaries = locSummaryQ.data ?? [];
|
||||
|
||||
const locations = (locationsQ.data ?? []).filter((loc) => {
|
||||
if (!searchQuery) return true;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return (
|
||||
str(loc.locationname).toLowerCase().includes(q) ||
|
||||
str(loc.city).toLowerCase().includes(q) ||
|
||||
str(loc.suburb).toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
// KPI cards — orders from getordersummary, profit from getinvoiceinsight.
|
||||
const totalStoresCount = locations.length;
|
||||
const activeStoresCount = locations.filter(l => str(l.status).toLowerCase() === 'active').length;
|
||||
const inactiveStoresCount = totalStoresCount - activeStoresCount;
|
||||
const activePct = totalStoresCount > 0 ? Math.round((activeStoresCount / totalStoresCount) * 100) : 0;
|
||||
const circumference = 251.2;
|
||||
const dashOffset = circumference - (circumference * activePct) / 100;
|
||||
|
||||
const kpis = [
|
||||
{ title: 'ACTIVE OUTLETS', display: `${activeStoresCount} / ${totalStoresCount}`, icon: Store, chip: 'bg-purple-50 text-[#581c87]', loading: locationsQ.isLoading },
|
||||
{ title: 'REGION FULFILLMENT', display: '98.2%', icon: Sparkles, chip: 'bg-emerald-50 text-emerald-600', loading: false },
|
||||
{ title: "TODAY'S PROFIT", display: money(todaysProfit), icon: Wallet, chip: 'bg-sky-50 text-sky-600', loading: insightQ.isLoading },
|
||||
{ title: 'MONTHLY PROFIT', display: money(monthlyProfit), icon: TrendingUp, chip: 'bg-emerald-50 text-emerald-600', loading: insightQ.isLoading },
|
||||
];
|
||||
|
||||
const statusRows = [
|
||||
{ label: 'Active Outlets', value: activeStoresCount, dot: 'bg-emerald-500' },
|
||||
{ label: 'Inactive / Maintenance', value: inactiveStoresCount, dot: 'bg-zinc-400' },
|
||||
];
|
||||
|
||||
const loading = summaryQ.isLoading;
|
||||
const errored = summaryQ.isError;
|
||||
|
||||
return (
|
||||
<div className="space-y-lg animate-in fade-in duration-500 relative">
|
||||
{/* Scope banner */}
|
||||
<div className="bg-[#faf5ff] border border-purple-100 rounded-xl p-md flex items-center justify-between shadow-[0_1px_3px_rgba(0,0,0,0.05)]">
|
||||
<div className="flex items-center gap-sm">
|
||||
<Sparkles size={16} className="text-[#581c87]" />
|
||||
<span className="font-sans text-xs text-zinc-700 font-medium">
|
||||
Live operations data for <strong>{tenantName}</strong> · {fromdate} → {todate}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<h1 className="font-sans font-bold text-3xl tracking-tight text-[#0f172a]">Executive Command Center</h1>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<p className="text-zinc-500 font-sans text-sm">Month-to-date order operations, pulled live from the API.</p>
|
||||
{loading ? (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-zinc-400 uppercase tracking-wide">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-zinc-300 animate-pulse" /> Loading…
|
||||
</span>
|
||||
) : errored ? (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-rose-600 uppercase tracking-wide" title="Restart the dev server so the /hasura proxy is active.">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-rose-500" /> Live data unavailable
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-emerald-600 uppercase tracking-wide">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" /> Live · {tenantName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error hint */}
|
||||
{errored && (
|
||||
<div className="bg-rose-50 border border-rose-200 rounded-xl p-md flex items-start gap-sm text-xs text-rose-800">
|
||||
<AlertTriangle size={16} className="shrink-0 mt-0.5 text-rose-500" />
|
||||
<div>
|
||||
<p className="font-semibold">Couldn't reach the live API.</p>
|
||||
<p className="mt-0.5 text-rose-700">
|
||||
The <code>/hasura</code> dev proxy loads at server start — stop and re-run <code>npm run dev</code> so the
|
||||
secret and proxy are active.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* KPI cards — all live from getordersummary */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-gutter">
|
||||
{kpis.map((kpi) => {
|
||||
const Icon = kpi.icon;
|
||||
return (
|
||||
<div
|
||||
key={kpi.title}
|
||||
className="group relative flex flex-col bg-white border border-[#eceef2] rounded-xl p-3 shadow-[0_1px_2px_rgba(16,24,40,0.04)] transition-all duration-200 hover:-translate-y-0.5 hover:border-purple-300 hover:shadow-[0_8px_22px_rgba(16,24,40,0.08)]"
|
||||
>
|
||||
<div className={`h-7 w-7 rounded-lg flex items-center justify-center ${kpi.chip}`}>
|
||||
<Icon size={14} />
|
||||
</div>
|
||||
<p className="text-[10px] font-semibold text-zinc-400 tracking-wider uppercase font-sans mt-3">
|
||||
{kpi.title}
|
||||
</p>
|
||||
<p className="font-sans font-bold text-2xl leading-tight text-[#0f172a] tracking-tight mt-0.5">
|
||||
{kpi.loading ? <span className="text-zinc-300">…</span> : kpi.display}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Order status + store locations */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-gutter">
|
||||
{/* Store Node Status donut (live) */}
|
||||
<div className="bg-white border border-[#e2e8f0] rounded-xl p-md flex flex-col shadow-[0_1px_3px_rgba(0,0,0,0.05)]">
|
||||
<div>
|
||||
<h3 className="font-sans font-bold text-base text-[#0f172a]">Store Outlet Status</h3>
|
||||
<p className="text-zinc-500 text-xs font-sans mt-0.5">Active share of all registered store nodes.</p>
|
||||
</div>
|
||||
|
||||
<div className="my-md flex justify-center items-center">
|
||||
<div className="relative w-40 h-40 flex items-center justify-center">
|
||||
<svg className="w-full h-full transform -rotate-90" viewBox="0 0 100 100">
|
||||
<circle cx="50" cy="50" r="40" fill="transparent" stroke="#eceef0" strokeWidth="8" />
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="40"
|
||||
fill="transparent"
|
||||
stroke="#10b981"
|
||||
strokeWidth="8"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={dashOffset}
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-700"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="font-sans font-bold text-3xl text-[#0f172a] tracking-tight">{activePct}%</span>
|
||||
<span className="text-[10px] text-emerald-600 uppercase tracking-widest font-semibold mt-1">Active</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-[#f1f5f9] text-xs">
|
||||
{statusRows.map((r) => (
|
||||
<div key={r.label} className="flex justify-between items-center py-2">
|
||||
<span className="flex items-center gap-1.5 text-zinc-500">
|
||||
<span className={`w-2.5 h-2.5 rounded-full ${r.dot}`} />
|
||||
{r.label}
|
||||
</span>
|
||||
<span className="font-mono font-bold text-zinc-700">{r.value.toLocaleString('en-IN')}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-between items-center py-2">
|
||||
<span className="text-zinc-500 font-semibold">Total Nodes</span>
|
||||
<span className="font-mono font-bold text-[#581c87]">{totalStoresCount.toLocaleString('en-IN')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Store locations (live) */}
|
||||
<div className="lg:col-span-2 bg-white border border-[#e2e8f0] rounded-xl p-md shadow-[0_1px_3px_rgba(0,0,0,0.05)]">
|
||||
<div className="flex justify-between items-center mb-md pb-xs border-b border-[#f1f5f9]">
|
||||
<h3 className="font-sans font-bold text-base text-[#0f172a] flex items-center gap-2">
|
||||
<Store size={16} className="text-[#581c87]" /> Store Locations
|
||||
</h3>
|
||||
<span className="text-[10px] text-[#581c87] uppercase font-bold bg-purple-50 px-2 py-0.5 rounded 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-zinc-400 text-xs">Loading store locations…</div>
|
||||
) : locations.length === 0 ? (
|
||||
<div className="text-center py-xl text-zinc-400 text-xs">No store locations found for this tenant.</div>
|
||||
) : (
|
||||
<div className="space-y-sm max-h-80 overflow-y-auto">
|
||||
{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);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={str(loc.locationid) || i}
|
||||
className="p-sm border border-[#e2e8f0] rounded-lg bg-[#f8fafc]/40 flex justify-between items-start gap-md hover:border-purple-200 transition-colors animate-in fade-in"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="font-sans font-semibold text-sm text-[#0f172a] truncate">{str(loc.locationname)}</p>
|
||||
<p className="text-[11px] text-zinc-500 mt-0.5 flex items-center gap-1">
|
||||
<MapPin size={11} className="shrink-0 text-zinc-400" />
|
||||
<span className="truncate">{str(loc.address) || `${str(loc.suburb)}, ${str(loc.city)}`}</span>
|
||||
</p>
|
||||
{str(loc.contactno) && (
|
||||
<p className="text-[11px] text-zinc-500 mt-0.5 flex items-center gap-1">
|
||||
<Phone size={11} className="shrink-0 text-zinc-400" />
|
||||
{str(loc.contactno)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Node-specific Orders and Dispatches */}
|
||||
<div className="flex items-center gap-3 mt-2.5">
|
||||
<span className="text-[10px] bg-purple-50 text-[#581c87] font-semibold px-2 py-0.5 rounded border border-purple-100/50">
|
||||
{orders} Orders
|
||||
</span>
|
||||
<span className="text-[10px] bg-emerald-50 text-emerald-700 font-semibold px-2 py-0.5 rounded border border-emerald-100/50">
|
||||
{deliveries} Dispatched
|
||||
</span>
|
||||
{orders > 0 && (
|
||||
<span className="text-[10px] text-zinc-400 font-medium">
|
||||
{Math.round((deliveries / orders) * 100)}% Fulfilled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 px-1.5 py-0.5 rounded text-[9px] font-bold uppercase ${
|
||||
str(loc.status).toLowerCase() === 'active'
|
||||
? 'text-emerald-600 bg-emerald-50 border border-emerald-100'
|
||||
: 'text-zinc-500 bg-zinc-100'
|
||||
}`}
|
||||
>
|
||||
{str(loc.status) || '—'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
174
src/components/Header.tsx
Normal file
174
src/components/Header.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Menu, Plus, HelpCircle, LogOut } from 'lucide-react';
|
||||
import { MainSection } from '../types';
|
||||
|
||||
interface HeaderProps {
|
||||
currentSection: MainSection;
|
||||
setCurrentSection: (section: MainSection) => void;
|
||||
isCoimbatoreView: boolean;
|
||||
onToggleSidebar: () => void;
|
||||
isSidebarOpen: boolean;
|
||||
onNewReportClick: () => void;
|
||||
onHelpClick: () => void;
|
||||
onLogoutClick: () => void;
|
||||
}
|
||||
|
||||
export default function Header({
|
||||
currentSection,
|
||||
setCurrentSection,
|
||||
isCoimbatoreView,
|
||||
onToggleSidebar,
|
||||
isSidebarOpen,
|
||||
onNewReportClick,
|
||||
onHelpClick,
|
||||
onLogoutClick
|
||||
}: HeaderProps) {
|
||||
const [showProfileDropdown, setShowProfileDropdown] = useState(false);
|
||||
const profileRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close the profile dropdown on any click/tap outside of it.
|
||||
useEffect(() => {
|
||||
if (!showProfileDropdown) return;
|
||||
const handlePointerDown = (e: MouseEvent | TouchEvent) => {
|
||||
if (profileRef.current && !profileRef.current.contains(e.target as Node)) {
|
||||
setShowProfileDropdown(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
document.addEventListener('touchstart', handlePointerDown);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handlePointerDown);
|
||||
document.removeEventListener('touchstart', handlePointerDown);
|
||||
};
|
||||
}, [showProfileDropdown]);
|
||||
|
||||
const profile = {
|
||||
name: 'Suresh Kumar',
|
||||
role: 'Operations Director',
|
||||
email: 'suresh.k@nearledaily.com',
|
||||
avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=150&q=80'
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="bg-[#581c87] border-b border-[#4c1d95] flex justify-between items-center w-full px-container-margin py-md fixed top-0 right-0 left-0 z-50 h-20 text-white shadow-sm">
|
||||
{/* Brand & Desktop Navigation Tabs */}
|
||||
<div className="flex items-center gap-md md:pl-0 pl-1">
|
||||
{/* Brand Logo — full wordmark when sidebar open, icon only when collapsed */}
|
||||
<span className="select-none flex items-center shrink-0">
|
||||
<img
|
||||
src={isSidebarOpen ? '/logo.png' : '/favicon.png'}
|
||||
alt="nearledaily logo"
|
||||
className="h-9 w-auto object-contain"
|
||||
/>
|
||||
</span>
|
||||
|
||||
{/* Sidebar toggle (Burger Menu) */}
|
||||
<button
|
||||
onClick={onToggleSidebar}
|
||||
title="Toggle sidebar"
|
||||
className="p-2 rounded-full hover:bg-purple-800 transition-colors cursor-pointer text-white"
|
||||
>
|
||||
<Menu size={18} />
|
||||
</button>
|
||||
|
||||
<nav className="hidden md:flex gap-lg items-center ml-2">
|
||||
<button
|
||||
onClick={() => setCurrentSection('dashboard')}
|
||||
className={`font-sans text-sm font-semibold cursor-pointer pb-1 transition-all ${
|
||||
currentSection === 'dashboard'
|
||||
? 'text-white border-b-2 border-white'
|
||||
: 'text-purple-200 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Dashboard
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setCurrentSection('operations')}
|
||||
className={`font-sans text-sm font-semibold cursor-pointer pb-1 transition-all ${
|
||||
currentSection === 'operations'
|
||||
? 'text-white border-b-2 border-white'
|
||||
: 'text-purple-200 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Operations
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setCurrentSection('reports')}
|
||||
className={`font-sans text-sm font-semibold cursor-pointer pb-1 transition-all ${
|
||||
currentSection === 'reports'
|
||||
? 'text-white border-b-2 border-white'
|
||||
: 'text-purple-200 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Reports
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Global Actions Bar */}
|
||||
<div className="flex items-center gap-md">
|
||||
{/* User profile with dropdown */}
|
||||
<div className="relative" ref={profileRef}>
|
||||
<button
|
||||
onClick={() => setShowProfileDropdown(!showProfileDropdown)}
|
||||
className="w-10 h-10 rounded-full overflow-hidden border border-purple-400 focus:ring-2 focus:ring-purple-300 outline-none cursor-pointer transition-transform duration-100 active:scale-95 flex items-center justify-center"
|
||||
>
|
||||
<img
|
||||
src={profile.avatar}
|
||||
alt="Executive Profile"
|
||||
referrerPolicy="no-referrer"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{showProfileDropdown && (
|
||||
<div className="absolute right-0 mt-2 w-56 bg-white border border-[#e2e8f0] rounded-lg shadow-xl py-2 z-50 text-zinc-700 animate-in fade-in duration-200">
|
||||
<div className="px-4 py-2 border-b border-[#f1f5f9] bg-[#f8fafc]">
|
||||
<p className="font-bold text-xs text-[#0f172a]">{profile.name}</p>
|
||||
<p className="text-[10px] text-zinc-400 font-medium">{profile.role}</p>
|
||||
</div>
|
||||
<div className="p-2 divide-y divide-[#f1f5f9]">
|
||||
<div className="py-1">
|
||||
<p className="px-2 text-[10px] text-zinc-400 font-semibold uppercase tracking-wider">Email</p>
|
||||
<p className="px-2 py-0.5 text-xs text-purple-600 font-sans font-medium truncate">{profile.email}</p>
|
||||
</div>
|
||||
|
||||
{/* Account actions (moved here from the sidebar) */}
|
||||
<div className="py-1 pt-2 flex flex-col gap-0.5">
|
||||
<button
|
||||
onClick={() => { setShowProfileDropdown(false); onNewReportClick(); }}
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-xs font-semibold text-zinc-700 hover:bg-zinc-50 cursor-pointer transition-colors"
|
||||
>
|
||||
<Plus size={14} className="text-[#581c87]" />
|
||||
New Report
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowProfileDropdown(false); onHelpClick(); }}
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-xs font-semibold text-zinc-700 hover:bg-zinc-50 cursor-pointer transition-colors"
|
||||
>
|
||||
<HelpCircle size={14} className="text-zinc-400" />
|
||||
Help Center
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowProfileDropdown(false); onLogoutClick(); }}
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-xs font-semibold text-rose-600 hover:bg-rose-50 cursor-pointer transition-colors"
|
||||
>
|
||||
<LogOut size={14} className="text-rose-500" />
|
||||
Log Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
901
src/components/InventoryView.tsx
Normal file
901
src/components/InventoryView.tsx
Normal file
@@ -0,0 +1,901 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Layers,
|
||||
Search,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
AlertTriangle,
|
||||
TrendingUp,
|
||||
Sparkles,
|
||||
Check,
|
||||
Package,
|
||||
ChevronRight,
|
||||
TrendingDown,
|
||||
Trash2,
|
||||
PackageCheck,
|
||||
Zap,
|
||||
Tag,
|
||||
UploadCloud,
|
||||
FileSpreadsheet,
|
||||
Palette,
|
||||
ShoppingBag,
|
||||
Info,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import { ProductMatrixItem, InventoryItem, ImportLog } from '../types';
|
||||
import { initialImportLogs } from '../data';
|
||||
import { useFiestaStockStatement, useFiestaTenantLocations } from '../services/fiestaQueries';
|
||||
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, str as fstr } from '../services/fiestaApi';
|
||||
import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers';
|
||||
|
||||
interface InventoryViewProps {
|
||||
searchQuery: string;
|
||||
isCoimbatoreView: boolean;
|
||||
}
|
||||
|
||||
export default function InventoryView({
|
||||
searchQuery,
|
||||
isCoimbatoreView
|
||||
}: InventoryViewProps) {
|
||||
// ── Live stock data (Fiesta) ─────────────────────────────────────────────
|
||||
// The catalog grid and the hub-balance ledger are both derived from the live
|
||||
// stock statement for the tenant's primary outlet. We seed local state from
|
||||
// it once it loads so the existing add / CSV / replenish interactions keep
|
||||
// mutating in-session without losing the live baseline.
|
||||
const locationsQ = useFiestaTenantLocations(FIESTA_TENANT_ID);
|
||||
const primaryLocation =
|
||||
(locationsQ.data ?? []).find((l) => Number(l.locationid) === FIESTA_PRIMARY_LOCATION_ID) ||
|
||||
(locationsQ.data ?? [])[0];
|
||||
const locationId = primaryLocation ? Number(primaryLocation.locationid) : FIESTA_PRIMARY_LOCATION_ID;
|
||||
const locationName = fstr(primaryLocation?.locationname) || 'Primary Outlet';
|
||||
|
||||
const stockQ = useFiestaStockStatement({
|
||||
tenantid: FIESTA_TENANT_ID,
|
||||
locationid: locationId,
|
||||
keyword: '',
|
||||
pageno: 1,
|
||||
pagesize: 100,
|
||||
});
|
||||
|
||||
const [products, setProducts] = useState<ProductMatrixItem[]>([]);
|
||||
const [inventory, setInventory] = useState<InventoryItem[]>([]);
|
||||
const [importLogs, setImportLogs] = useState<ImportLog[]>(initialImportLogs);
|
||||
|
||||
useEffect(() => {
|
||||
if (stockQ.data) {
|
||||
setProducts(stockQ.data.map(stockRowToProduct));
|
||||
setInventory(stockQ.data.map((r) => stockRowToInventory(r, locationName)));
|
||||
}
|
||||
// locationName is derived from the same query chain; safe to depend on data.
|
||||
}, [stockQ.data, locationName]);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'catalog' | 'import_branding'>('catalog');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('ALL');
|
||||
const [showAddProductModal, setShowAddProductModal] = useState(false);
|
||||
const [replenishmentList, setReplenishmentList] = useState<string[]>([]);
|
||||
|
||||
// CSV Textarea input
|
||||
const [csvText, setCsvText] = useState(
|
||||
"Name, SKU, Category, Price, InitialStock\nAmma Ghee Pure Butter, GHEE-AMMA-1L, Groceries / Oils, 640, 200\nBhavani Ponni Sona Rice, ST-SONA-25K, Staples / Rice, 1350, 150"
|
||||
);
|
||||
|
||||
// Brand designs state
|
||||
const [brandStyle, setBrandStyle] = useState({
|
||||
themeName: 'Coimbatore Kaveri Org',
|
||||
primaryColor: '#16a34a', // Emerald
|
||||
secondaryColor: '#f59e0b', // Amber
|
||||
bagLabel: 'Freshly Harvested from Tamil Soil',
|
||||
isEcoVerified: true,
|
||||
stickerPattern: 'radial'
|
||||
});
|
||||
|
||||
// Form state for individual adding
|
||||
const [newProduct, setNewProduct] = useState({
|
||||
name: '',
|
||||
sku: '',
|
||||
category: 'Staples / Rice',
|
||||
price: 150,
|
||||
initialStock: 250,
|
||||
image: 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200'
|
||||
});
|
||||
|
||||
// Categories derived from the live catalog (falls back to ALL only).
|
||||
const categorySet = new Set<string>();
|
||||
products.forEach((p) => categorySet.add(p.category));
|
||||
const categories: string[] = ['ALL', ...Array.from(categorySet)];
|
||||
|
||||
// Handle SKU quantity change
|
||||
const handleUpdateStock = (sku: string, delta: number) => {
|
||||
setInventory(prev => prev.map(item => {
|
||||
if (item.sku === sku) {
|
||||
const newLevel = Math.max(0, item.stockLevel + delta);
|
||||
const status = newLevel < 25 ? 'Critical' : newLevel < 120 ? 'Low Stock' : 'Optimal';
|
||||
return { ...item, stockLevel: newLevel, status };
|
||||
}
|
||||
return item;
|
||||
}));
|
||||
};
|
||||
|
||||
// Trigger quick reorder recommendation
|
||||
const handleReplenishSku = (sku: string) => {
|
||||
if (replenishmentList.includes(sku)) return;
|
||||
setReplenishmentList(prev => [...prev, sku]);
|
||||
handleUpdateStock(sku, 500); // Add 500 units to stock
|
||||
setTimeout(() => {
|
||||
alert(`Auto-Replenish complete! 500 units ordered and allocated directly to corresponding hub for SKU ${sku}`);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// Filter criteria
|
||||
const filteredProducts = products.filter(p => {
|
||||
const matchesSearch = p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
p.sku.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
p.category.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesCat = selectedCategory === 'ALL' || p.category.startsWith(selectedCategory.split(' / ')[0]);
|
||||
return matchesSearch && matchesCat;
|
||||
});
|
||||
|
||||
const handleAddNewProduct = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newProduct.name || !newProduct.sku) {
|
||||
alert('Kindly supply correct product specifications and catalog SKU code.');
|
||||
return;
|
||||
}
|
||||
|
||||
const createdProd: ProductMatrixItem = {
|
||||
id: String(products.length + 1),
|
||||
name: newProduct.name,
|
||||
sku: newProduct.sku,
|
||||
unitsSold: 0,
|
||||
revenue: 0,
|
||||
stockStatus: 'Healthy',
|
||||
trend: 'flat',
|
||||
image: newProduct.image,
|
||||
category: newProduct.category,
|
||||
exposure: 'All Outlets',
|
||||
verified: true
|
||||
};
|
||||
|
||||
const createdInv: InventoryItem = {
|
||||
sku: newProduct.sku,
|
||||
name: newProduct.name,
|
||||
warehouse: 'RS Puram Hub (CBE-01)',
|
||||
stockLevel: newProduct.initialStock,
|
||||
maxCapacity: 1000,
|
||||
status: 'Optimal',
|
||||
region: 'CBE-NORTH'
|
||||
};
|
||||
|
||||
setProducts([createdProd, ...products]);
|
||||
setInventory([createdInv, ...inventory]);
|
||||
setShowAddProductModal(false);
|
||||
alert(`Fresh product "${createdProd.name}" incorporated into Master Grocery Catalog and standard ledger!`);
|
||||
|
||||
setNewProduct({
|
||||
name: '',
|
||||
sku: '',
|
||||
category: 'Staples / Rice',
|
||||
price: 150,
|
||||
initialStock: 250,
|
||||
image: 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200'
|
||||
});
|
||||
};
|
||||
|
||||
// Custom Raw CSV import
|
||||
const handleCSVImport = () => {
|
||||
const lines = csvText.split('\n').map(l => l.trim()).filter(l => l.length > 0 && !l.startsWith('Name'));
|
||||
if (lines.length === 0) {
|
||||
alert('CSV sequence contains no importable entries.');
|
||||
return;
|
||||
}
|
||||
|
||||
let parsedCount = 0;
|
||||
const newProds: ProductMatrixItem[] = [];
|
||||
const newInvs: InventoryItem[] = [];
|
||||
|
||||
lines.forEach(line => {
|
||||
const parts = line.split(',').map(p => p.trim());
|
||||
if (parts.length >= 2) {
|
||||
const name = parts[0];
|
||||
const sku = parts[1];
|
||||
const category = parts[2] || 'Staples / Rice';
|
||||
const price = Number(parts[3]) || 120;
|
||||
const initialStock = Number(parts[4]) || 150;
|
||||
|
||||
if (!products.some(p => p.sku === sku)) {
|
||||
newProds.push({
|
||||
id: String(products.length + newProds.length + 1),
|
||||
name,
|
||||
sku,
|
||||
unitsSold: 0,
|
||||
revenue: 0,
|
||||
stockStatus: 'Healthy',
|
||||
trend: 'flat',
|
||||
image: 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200',
|
||||
category,
|
||||
exposure: 'All Outlets',
|
||||
verified: true
|
||||
});
|
||||
|
||||
newInvs.push({
|
||||
sku,
|
||||
name,
|
||||
warehouse: 'RS Puram Hub (CBE-01)',
|
||||
stockLevel: initialStock,
|
||||
maxCapacity: 1000,
|
||||
status: 'Optimal',
|
||||
region: 'CBE-NORTH'
|
||||
});
|
||||
parsedCount++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (parsedCount > 0) {
|
||||
setProducts(prev => [...newProds, ...prev]);
|
||||
setInventory(prev => [...newInvs, ...prev]);
|
||||
|
||||
const logEntry: ImportLog = {
|
||||
timestamp: new Date().toLocaleTimeString() + ' (IST)',
|
||||
batchRef: `#IMP_CSV_${Math.floor(1000 + Math.random() * 9000)}`,
|
||||
type: 'CSV Catalogue Import',
|
||||
source: 'Console Upload',
|
||||
result: `SUCCESS (Parsed ${parsedCount} rows)`,
|
||||
status: 'SUCCESS'
|
||||
};
|
||||
setImportLogs([logEntry, ...importLogs]);
|
||||
alert(`Synchronized ${parsedCount} regional products into Catalog database successfully!`);
|
||||
} else {
|
||||
alert('All the specified SKU codes are already active in the catalog ledger.');
|
||||
}
|
||||
};
|
||||
|
||||
// Preset import trigger
|
||||
const handleImportPreset = (presetName: string, itemsList: Array<{name: string, sku: string, cat: string, price: number, stock: number, img: string}>) => {
|
||||
let imported = 0;
|
||||
const newProds: ProductMatrixItem[] = [];
|
||||
const newInvs: InventoryItem[] = [];
|
||||
|
||||
itemsList.forEach((itm) => {
|
||||
if (!products.some(p => p.sku === itm.sku)) {
|
||||
newProds.push({
|
||||
id: String(products.length + newProds.length + 20),
|
||||
name: itm.name,
|
||||
sku: itm.sku,
|
||||
unitsSold: Math.floor(Math.random() * 45 + 15),
|
||||
revenue: Math.floor(Math.random() * 20000 + 4000),
|
||||
stockStatus: 'Healthy',
|
||||
trend: 'up',
|
||||
image: itm.img,
|
||||
category: itm.cat,
|
||||
exposure: 'All Outlets',
|
||||
verified: true
|
||||
});
|
||||
|
||||
newInvs.push({
|
||||
sku: itm.sku,
|
||||
name: itm.name,
|
||||
warehouse: 'Peelamedu Sort Center',
|
||||
stockLevel: itm.stock,
|
||||
maxCapacity: 800,
|
||||
status: 'Optimal',
|
||||
region: 'CBE-EAST'
|
||||
});
|
||||
imported++;
|
||||
}
|
||||
});
|
||||
|
||||
if (imported > 0) {
|
||||
setProducts(prev => [...newProds, ...prev]);
|
||||
setInventory(prev => [...newInvs, ...prev]);
|
||||
|
||||
const logEntry: ImportLog = {
|
||||
timestamp: new Date().toLocaleTimeString() + ' (IST)',
|
||||
batchRef: `#IMP_PST_${Math.floor(1000 + Math.random() * 9000)}`,
|
||||
type: `${presetName} Import`,
|
||||
source: 'Corporate Cloud Feed',
|
||||
result: `SUCCESS Onboarded (${imported} SKUs)`,
|
||||
status: 'SUCCESS'
|
||||
};
|
||||
setImportLogs([logEntry, ...importLogs]);
|
||||
alert(`Successfully mapped and onboarded ${imported} brand SKUs from "${presetName}"!`);
|
||||
} else {
|
||||
alert('All elements of this retail catalog preset are already assigned.');
|
||||
}
|
||||
};
|
||||
|
||||
// Nilgiris Presets
|
||||
const nilgirisDairy = [
|
||||
{ name: 'Ooty Hills Creamery Butter 500g', sku: 'DY-OOT-BTR', cat: 'Groceries / Oils', price: 340, stock: 210, img: 'https://images.unsplash.com/photo-1589985270826-4b7bb135bc9d?auto=format&fit=crop&q=80&w=200' },
|
||||
{ name: 'Nilgiris Mountain Farm Cheese 250g', sku: 'DY-NIL-CHS', cat: 'Groceries / Oils', price: 460, stock: 120, img: 'https://images.unsplash.com/photo-1486887396153-fa416525c108?auto=format&fit=crop&q=80&w=200' },
|
||||
{ name: 'Aavin Premium Ghee Tin 1L', sku: 'DY-AAV-GHEE', cat: 'Groceries / Oils', price: 680, stock: 180, img: 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200' }
|
||||
];
|
||||
|
||||
// Coimbatore Crops
|
||||
const cbeHeritage = [
|
||||
{ name: 'Bhavani Premium Boiled Rice 10kg', sku: 'ST-BHV-RICE', cat: 'Staples / Rice', price: 740, stock: 350, img: 'https://images.unsplash.com/photo-1586201375761-83865001e31c?auto=format&fit=crop&q=80&w=200' },
|
||||
{ name: 'Pollachi Clean Gram Dhal 2kg', sku: 'ST-POL-DHAL', cat: 'Staples / Rice', price: 185, stock: 240, img: 'https://images.unsplash.com/photo-1596040033229-a9821ebd058d?auto=format&fit=crop&q=80&w=200' },
|
||||
{ name: 'Pure Wood Pressed Gingelly Oil 1L', sku: 'ST-OIL-WOOD', cat: 'Groceries / Oils', price: 395, stock: 190, img: 'https://images.unsplash.com/photo-1474979266404-7eaacbcd87c5?auto=format&fit=crop&q=80&w=200' }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-lg animate-in fade-in duration-500">
|
||||
|
||||
{/* Dynamic Navigation Toolbar header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-md border-b border-[#e2e8f0] pb-md">
|
||||
<div>
|
||||
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a] flex items-center gap-xs">
|
||||
<Layers className="text-[#581c87]" size={24} />
|
||||
Coimbatore Grocery Assortment & Catalogue Studio
|
||||
</h1>
|
||||
<p className="text-zinc-500 font-sans text-xs mt-1">
|
||||
Build regional catalogues, update localized stock balances, parse batch imports, and style brand bag templates.
|
||||
</p>
|
||||
<div className="mt-1.5">
|
||||
{stockQ.isLoading ? (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-zinc-400 uppercase tracking-wide">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-zinc-300 animate-pulse" /> Loading live stock…
|
||||
</span>
|
||||
) : stockQ.isError ? (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-rose-600 uppercase tracking-wide">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-rose-500" /> Live data unavailable
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-emerald-600 uppercase tracking-wide">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" /> Live · {locationName} · {products.length} SKUs
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-sm">
|
||||
<button
|
||||
onClick={() => setActiveTab('catalog')}
|
||||
className={`px-4 py-2 rounded-lg text-xs font-bold transition-all cursor-pointer ${
|
||||
activeTab === 'catalog'
|
||||
? 'bg-[#581c87] text-white shadow-sm'
|
||||
: 'bg-white hover:bg-zinc-50 text-zinc-700 border border-[#e2e8f0]'
|
||||
}`}
|
||||
>
|
||||
🌾 Catalog Grid & Ledger
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('import_branding')}
|
||||
className={`px-4 py-2 rounded-lg text-xs font-bold transition-all cursor-pointer ${
|
||||
activeTab === 'import_branding'
|
||||
? 'bg-[#581c87] text-white shadow-sm'
|
||||
: 'bg-white hover:bg-zinc-50 text-zinc-700 border border-[#e2e8f0]'
|
||||
}`}
|
||||
>
|
||||
📥 Import & Brand Studio
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTab === 'catalog' ? (
|
||||
<>
|
||||
{/* Quick Category Tab Filter Row */}
|
||||
<div className="flex flex-wrap gap-2 py-1 items-center justify-between">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setSelectedCategory(cat)}
|
||||
className={`px-4 py-2 rounded-lg font-sans text-xs font-semibold tracking-wide transition-all border outline-none cursor-pointer ${
|
||||
selectedCategory === cat
|
||||
? 'bg-[#581c87] text-white border-[#581c87] shadow-sm'
|
||||
: 'bg-white text-zinc-700 border-[#e2e8f0] hover:bg-zinc-50'
|
||||
}`}
|
||||
>
|
||||
{cat === 'ALL' ? '🌾 All Catalog Items' : cat.replace('Groceries / ', '').replace('Staples / ', '').replace('Beverages / ', '').replace('Fresh Produce / ', '')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowAddProductModal(true)}
|
||||
className="bg-[#581c87] text-white px-xl py-2 rounded-lg text-xs font-bold uppercase tracking-wider flex items-center justify-center gap-xs cursor-pointer hover:bg-purple-800 transition shadow-sm"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add Manual SKU
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Multi-Pane Layout: Left Catalog Grid, Right Stock balances */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-gutter text-xs font-sans">
|
||||
|
||||
{/* Left Grid: Grocery Catalogue Items Showcase */}
|
||||
<div className="lg:col-span-2 space-y-md">
|
||||
<div className="bg-[#f8fafc]/50 border border-[#e2e8f0] p-md rounded-xl">
|
||||
<h3 className="font-sans font-bold text-sm text-[#0f172a] mb-xs">Active Assortment Items</h3>
|
||||
<p className="text-zinc-500 font-normal mb-md leading-relaxed text-[11px]">Primary catalog schema synchronized on customer booking apps. Total: {filteredProducts.length} items</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-sm">
|
||||
{filteredProducts.map((prod) => {
|
||||
return (
|
||||
<div key={prod.id} className="bg-white border border-[#e2e8f0] rounded-xl overflow-hidden p-md flex gap-md shadow-sm hover:shadow-md transition-shadow relative">
|
||||
<div className="w-16 h-16 rounded-xl border border-zinc-100 shrink-0 overflow-hidden bg-zinc-50">
|
||||
<img
|
||||
src={prod.image}
|
||||
alt={prod.name}
|
||||
referrerPolicy="no-referrer"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h4 className="font-bold text-zinc-900 leading-tight text-xs">{prod.name}</h4>
|
||||
<span className="text-[10px] text-zinc-400 font-bold tracking-tight">{prod.sku}</span>
|
||||
</div>
|
||||
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold uppercase bg-purple-50 text-purple-700">
|
||||
{prod.category.split(' / ')[0]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pt-2">
|
||||
<div>
|
||||
<span className="text-[9px] text-zinc-400 block uppercase tracking-wider font-bold">Sold (Units)</span>
|
||||
<span className="font-bold text-zinc-800 font-mono">{prod.unitsSold.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-[9px] text-zinc-400 block uppercase tracking-wider font-bold">Total revenue</span>
|
||||
<span className="font-bold text-emerald-600 font-mono">₹{prod.revenue.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Pane: Stock level adjustment ledgers */}
|
||||
<div className="space-y-md">
|
||||
<div className="bg-white border border-[#e2e8f0] rounded-xl p-md shadow-sm space-y-md">
|
||||
<div>
|
||||
<h3 className="font-sans font-bold text-sm text-[#0f172a]">Hub Balances Ledger</h3>
|
||||
<p className="text-zinc-500 font-normal leading-relaxed text-[11px] mt-0.5">Physical checkout balances across localized Coimbatore warehouse locations.</p>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-[#f1f5f9] select-none">
|
||||
{inventory.map((item, idx) => {
|
||||
const percentage = (item.stockLevel / item.maxCapacity) * 100;
|
||||
return (
|
||||
<div key={idx} className="py-md space-y-xs">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<p className="font-bold text-[#0f172a]">{item.name}</p>
|
||||
<p className="text-[10px] text-zinc-400 mt-1 font-medium">{item.warehouse}</p>
|
||||
<div className="flex gap-px pt-1 items-center">
|
||||
<span className="bg-[#f1f5f9] px-1 py-0.5 rounded text-[8px] font-bold text-zinc-500 font-mono tracking-tight mr-1">{item.region}</span>
|
||||
<span className={`px-1.5 py-0.5 rounded text-[8px] font-bold tracking-wide uppercase ${
|
||||
item.status === 'Critical' ? 'bg-rose-50 text-rose-600 border border-rose-100 animate-pulse' : item.status === 'Low Stock' ? 'bg-amber-50 text-amber-600' : 'bg-emerald-50 text-emerald-600'
|
||||
}`}>
|
||||
● {item.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right space-y-1">
|
||||
<span className="font-mono font-bold text-[#0f172a] block">{item.stockLevel.toLocaleString()} units</span>
|
||||
|
||||
<div className="flex gap-1 justify-end">
|
||||
<button
|
||||
className="bg-zinc-100 hover:bg-zinc-200 p-1 px-2 rounded font-bold cursor-pointer text-[10px]"
|
||||
onClick={() => handleUpdateStock(item.sku, -5)}
|
||||
title="Decrement 5 units"
|
||||
>
|
||||
-5
|
||||
</button>
|
||||
<button
|
||||
className="bg-zinc-100 hover:bg-zinc-200 p-1 px-2 rounded font-bold cursor-pointer text-[10px]"
|
||||
onClick={() => handleUpdateStock(item.sku, 5)}
|
||||
title="Increment 5 units"
|
||||
>
|
||||
+5
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gauge percentage */}
|
||||
<div className="pt-1.5 space-y-1">
|
||||
<div className="w-full bg-[#eceef0] h-1.5 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${
|
||||
item.status === 'Critical' ? 'bg-rose-500' : item.status === 'Low Stock' ? 'bg-amber-500' : 'bg-[#581c87]'
|
||||
}`}
|
||||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-[9px] text-zinc-400 font-bold">
|
||||
<span>Verification Level: {Math.round(percentage)}%</span>
|
||||
{item.status !== 'Optimal' && (
|
||||
<button
|
||||
onClick={() => handleReplenishSku(item.sku)}
|
||||
className="text-[#581c87] hover:underline flex items-center gap-px font-bold cursor-pointer"
|
||||
>
|
||||
<Zap size={11} className="text-amber-500 animate-bounce" />
|
||||
Auto-Replenish
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-gutter text-xs font-sans">
|
||||
|
||||
{/* Left Column: Catalogue Import & Batch Console */}
|
||||
<div className="space-y-lg">
|
||||
|
||||
{/* Fast Imports presets Card */}
|
||||
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl shadow-sm space-y-md">
|
||||
<div className="flex items-center gap-xs text-[#0f172a] font-bold text-sm">
|
||||
<Sparkles className="text-amber-500" size={18} />
|
||||
<h3>Tamil Nadu Region Catalog Presets</h3>
|
||||
</div>
|
||||
<p className="text-zinc-500 leading-relaxed text-[11px]">
|
||||
Instantly import bulk verified grocers, spices and diary products catalogs from local Coimbatore farms & cooperatives.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-sm">
|
||||
|
||||
{/* Preset 1 */}
|
||||
<div className="border border-[#e2e8f0] rounded-xl p-sm space-y-md hover:border-purple-300 transition-colors bg-[#f8fafc]/30">
|
||||
<div>
|
||||
<h4 className="font-bold text-zinc-900 leading-tight">Nilgiris Dairy Fresh Pack</h4>
|
||||
<p className="text-[10px] text-zinc-400 mt-0.5">3 High-Margin Butter & Cheese SKU</p>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[10px] font-mono font-bold text-[#581c87]">CBE-COOP-04</span>
|
||||
<button
|
||||
onClick={() => handleImportPreset('Nilgiris Dairy Coop', nilgirisDairy)}
|
||||
className="px-2 py-1 bg-[#581c87] hover:bg-purple-800 text-white font-bold rounded text-[9px] uppercase cursor-pointer"
|
||||
>
|
||||
Import Batch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preset 2 */}
|
||||
<div className="border border-[#e2e8f0] rounded-xl p-sm space-y-md hover:border-purple-300 transition-colors bg-[#f8fafc]/30">
|
||||
<div>
|
||||
<h4 className="font-bold text-zinc-900 leading-tight">Coimbatore Heritage Grains</h4>
|
||||
<p className="text-[10px] text-zinc-400 mt-0.5">3 Premium Boiled Rice & Oils</p>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[10px] font-mono font-bold text-emerald-600">TAMIL-AGRI-09</span>
|
||||
<button
|
||||
onClick={() => handleImportPreset('Coimbatore Heritage', cbeHeritage)}
|
||||
className="px-2 py-1 bg-[#581c87] hover:bg-purple-800 text-white font-bold rounded text-[9px] uppercase cursor-pointer"
|
||||
>
|
||||
Import Batch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom CSV Parsing Box */}
|
||||
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl shadow-sm space-y-md">
|
||||
<div className="flex items-center gap-xs text-[#0f172a] font-bold text-sm">
|
||||
<FileSpreadsheet className="text-[#581c87]" size={18} />
|
||||
<h3>Manual CSV Direct-Entry Console</h3>
|
||||
</div>
|
||||
<p className="text-zinc-500 text-[11px]">
|
||||
Paste comma-separated rows here (Name, SKU, Category, Price, InitialStock) to bulk register catalog elements.
|
||||
</p>
|
||||
|
||||
<div className="space-y-sm">
|
||||
<textarea
|
||||
value={csvText}
|
||||
onChange={(e) => setCsvText(e.target.value)}
|
||||
className="w-full h-28 p-sm font-mono text-[11px] border border-[#e2e8f0] rounded-lg bg-[#f8fafc] outline-none focus:bg-white focus:ring-1 focus:ring-[#581c87] leading-relaxed"
|
||||
/>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[10px] text-zinc-400 font-medium">Header line is skipped automatically.</span>
|
||||
<button
|
||||
onClick={handleCSVImport}
|
||||
className="bg-[#581c87] text-white px-xl py-2 rounded-lg text-xs font-bold uppercase tracking-wider cursor-pointer hover:bg-purple-800 transition"
|
||||
>
|
||||
Parse CSV Data & Sync
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Realtime Import Logs list */}
|
||||
<div className="bg-[#f8fafc]/50 border border-[#e2e8f0] p-md rounded-xl">
|
||||
<h3 className="font-sans font-bold text-sm text-[#0f172a] mb-xs">Live Channel Import Logs & Audit</h3>
|
||||
<p className="text-zinc-505 mb-md text-[11px]">Recent logistics synchronization log sequences executed by central Coimbatore ERP.</p>
|
||||
|
||||
<div className="space-y-sm">
|
||||
{importLogs.map((log, idx) => (
|
||||
<div key={idx} className="bg-white p-sm border border-[#e2e8f0] rounded-lg flex items-center justify-between text-xs font-sans">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-sm">
|
||||
<span className="font-mono font-bold text-[#581c87]">{log.batchRef}</span>
|
||||
<span className="text-zinc-400 text-[10px] font-medium">{log.timestamp}</span>
|
||||
</div>
|
||||
<p className="font-bold text-zinc-800">{log.type} • <em className="text-zinc-400 font-normal">{log.source}</em></p>
|
||||
</div>
|
||||
|
||||
<span className="px-1.5 py-0.5 bg-emerald-50 border border-emerald-100 text-emerald-600 font-bold uppercase text-[9px] rounded">
|
||||
{log.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Right Column: Beautiful Interactive Brand Design Studio */}
|
||||
<div className="space-y-lg">
|
||||
|
||||
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl shadow-sm space-y-md">
|
||||
<div className="flex items-center gap-xs text-[#0f172a] font-bold text-sm">
|
||||
<Palette className="text-[#581c87]" size={18} />
|
||||
<h3>Operational Branding & Package Studio</h3>
|
||||
</div>
|
||||
<p className="text-zinc-500 leading-relaxed text-[11px]">
|
||||
Grocery apps and parcel delivery bags use custom generated corporate brand designs. Style bag backgrounds, badges, and titles live.
|
||||
</p>
|
||||
|
||||
<div className="space-y-md text-xs">
|
||||
|
||||
{/* Studio Control 1 */}
|
||||
<div className="space-y-1">
|
||||
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">BRAND THEME CAPTION</label>
|
||||
<input
|
||||
type="text"
|
||||
value={brandStyle.themeName}
|
||||
onChange={(e) => setBrandStyle({ ...brandStyle, themeName: e.target.value })}
|
||||
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#581c87]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Studio Control 2 */}
|
||||
<div className="grid grid-cols-2 gap-sm">
|
||||
<div className="space-y-1">
|
||||
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">PRIMARY BACKPLANE COLOR</label>
|
||||
<div className="flex gap-sm items-center">
|
||||
<input
|
||||
type="color"
|
||||
value={brandStyle.primaryColor}
|
||||
onChange={(e) => setBrandStyle({ ...brandStyle, primaryColor: e.target.value })}
|
||||
className="w-8 h-8 rounded border border-zinc-200 cursor-pointer"
|
||||
/>
|
||||
<span className="font-mono font-bold text-zinc-700">{brandStyle.primaryColor}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">ACCENT TEXT COLOR</label>
|
||||
<div className="flex gap-sm items-center">
|
||||
<input
|
||||
type="color"
|
||||
value={brandStyle.secondaryColor}
|
||||
onChange={(e) => setBrandStyle({ ...brandStyle, secondaryColor: e.target.value })}
|
||||
className="w-8 h-8 rounded border border-zinc-200 cursor-pointer"
|
||||
/>
|
||||
<span className="font-mono font-bold text-zinc-700">{brandStyle.secondaryColor}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Studio Control 3 */}
|
||||
<div className="space-y-1">
|
||||
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">BAG PRINT FOOTER TAG</label>
|
||||
<input
|
||||
type="text"
|
||||
value={brandStyle.bagLabel}
|
||||
onChange={(e) => setBrandStyle({ ...brandStyle, bagLabel: e.target.value })}
|
||||
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#581c87]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Studio Control 4 */}
|
||||
<div className="flex items-center justify-between p-sm bg-[#f8fafc] border border-zinc-200/50 rounded-lg">
|
||||
<div>
|
||||
<h4 className="font-bold text-zinc-900 text-xs">Acknowledge Eco-Certified Badge</h4>
|
||||
<p className="text-[10px] text-zinc-400 mt-0.5">Prints stamp acknowledging sustainable jute bag usage.</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={brandStyle.isEcoVerified}
|
||||
onChange={() => setBrandStyle({ ...brandStyle, isEcoVerified: !brandStyle.isEcoVerified })}
|
||||
className="w-4 h-4 text-emerald-600 border-[#e2e8f0] rounded focus:ring-0 outline-none cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Interactive Dynamic Checkout Jute Bag Preview Canvas */}
|
||||
<div className="border border-[#e2e8f0] rounded-xl p-md bg-zinc-50 space-y-sm">
|
||||
<span className="text-[9px] font-sans font-bold text-zinc-400 uppercase tracking-widest block text-center border-b border-zinc-200 pb-1">
|
||||
Live Packaged Grocery Bag Design Preview
|
||||
</span>
|
||||
|
||||
<div className="relative mx-auto w-48 h-64 bg-[#efe5d9] border-2 border-[#d2b48c] rounded-b-2xl rounded-t-lg shadow-inner flex flex-col justify-between p-sm">
|
||||
|
||||
{/* Hanging handle simulation */}
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 w-20 h-5 border-2 border-b-0 border-[#d2b48c] rounded-t-full" />
|
||||
|
||||
<div className="text-center pt-md space-y-1">
|
||||
<span className="text-[10px] font-bold block tracking-tight uppercase" style={{ color: brandStyle.primaryColor }}>
|
||||
{brandStyle.themeName || 'nearledaily Fresh'}
|
||||
</span>
|
||||
<div className="w-12 h-0.5 mx-auto bg-amber-500" style={{ backgroundColor: brandStyle.secondaryColor }} />
|
||||
</div>
|
||||
|
||||
<div className="my-auto flex flex-col items-center text-center p-1 space-y-1">
|
||||
<ShoppingBag className="w-10 h-10 stroke-1" style={{ color: brandStyle.primaryColor }} />
|
||||
<span className="text-[9px] font-medium max-w-[130px] leading-tight block text-zinc-700">
|
||||
{brandStyle.bagLabel || 'Grown with Pride'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center text-[8px] border-t border-zinc-300 pt-1">
|
||||
<span className="font-bold text-zinc-500">100% COMPOSTABLE</span>
|
||||
{brandStyle.isEcoVerified && (
|
||||
<span className="text-emerald-700 font-bold bg-emerald-100 px-1 py-0.5 rounded text-[7px]">
|
||||
CBE-ECO
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-[10px] text-zinc-405 font-medium">Standard printed thermal stamps scale according to the preview.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CREATE NEW PRODUCT MODAL PORTAL */}
|
||||
{showAddProductModal && (
|
||||
<div
|
||||
className="fixed inset-0 bg-[#0f172a]/40 backdrop-blur-sm z-[200] flex items-center justify-center p-md"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setShowAddProductModal(false); }}
|
||||
>
|
||||
<div className="bg-white border border-[#e2e8f0] rounded-xl w-full max-w-[28rem] max-h-[90vh] flex flex-col shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200 text-xs font-sans cursor-default">
|
||||
<div className="p-md border-b border-[#e2e8f0] bg-[#f8fafc] flex justify-between items-center shrink-0">
|
||||
<h4 className="font-bold text-[#0f172a] flex items-center gap-xs">
|
||||
<Package size={15} className="text-[#581c87]" />
|
||||
Introduce New Grocery Catalog SKU
|
||||
</h4>
|
||||
<button
|
||||
onClick={() => setShowAddProductModal(false)}
|
||||
className="p-1 hover:bg-zinc-200 rounded-full text-zinc-400 cursor-pointer transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleAddNewProduct} className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<div className="p-md space-y-md overflow-y-auto flex-1">
|
||||
<div className="space-y-sm">
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">PRODUCT BRAND NAME (*)</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Aavin Pure Cow Ghee"
|
||||
value={newProduct.name}
|
||||
onChange={(e) => setNewProduct({ ...newProduct, name: e.target.value })}
|
||||
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#581c87]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-sm">
|
||||
<div className="space-y-1">
|
||||
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">SKU CODE IDENTIFIER (*)</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. GHEE-AAV-500"
|
||||
value={newProduct.sku}
|
||||
onChange={(e) => setNewProduct({ ...newProduct, sku: e.target.value })}
|
||||
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#581c87] font-mono"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">CATEGORY SEGMENT</label>
|
||||
<select
|
||||
value={newProduct.category}
|
||||
onChange={(e) => setNewProduct({ ...newProduct, category: e.target.value })}
|
||||
className="w-full border border-[#e2e8f0] rounded-lg p-2 bg-[#f8fafc] focus:bg-white outline-none"
|
||||
>
|
||||
<option value="Staples / Rice">Staples / Rice</option>
|
||||
<option value="Groceries / Oils">Groceries / Oils</option>
|
||||
<option value="Beverages / Coffee">Beverages / Coffee</option>
|
||||
<option value="Fresh Produce / Veg">Fresh Produce / Veg</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-sm">
|
||||
<div className="space-y-1">
|
||||
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">ESTIMATED price (₹)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={newProduct.price}
|
||||
onChange={(e) => setNewProduct({ ...newProduct, price: Number(e.target.value) })}
|
||||
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#581c87]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">INITIAL ALLOCATED BALANCES</label>
|
||||
<input
|
||||
type="number"
|
||||
value={newProduct.initialStock}
|
||||
onChange={(e) => setNewProduct({ ...newProduct, initialStock: Number(e.target.value) })}
|
||||
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#581c87]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">PRODUCT IMAGE PATH OR LINK</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newProduct.image}
|
||||
onChange={(e) => setNewProduct({ ...newProduct, image: e.target.value })}
|
||||
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#581c87]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-md border-t border-[#f1f5f9] flex justify-end gap-sm bg-[#f8fafc] shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddProductModal(false)}
|
||||
className="px-4 py-2 border border-[#e2e8f0] rounded-lg font-semibold text-zinc-500 hover:bg-zinc-50 cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-[#581c87] text-white rounded-lg font-bold hover:bg-purple-800 cursor-pointer shadow-sm"
|
||||
>
|
||||
Commit Product Design SKU
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
989
src/components/OperationsView.tsx
Normal file
989
src/components/OperationsView.tsx
Normal file
@@ -0,0 +1,989 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Layers,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
HelpCircle,
|
||||
ArrowUpRight,
|
||||
TrendingUp,
|
||||
Sliders,
|
||||
DollarSign,
|
||||
PackageCheck,
|
||||
PlusSquare,
|
||||
ArrowRightLeft,
|
||||
XCircle,
|
||||
FolderSync,
|
||||
UploadCloud,
|
||||
FileCheck,
|
||||
Download,
|
||||
AlertOctagon,
|
||||
X,
|
||||
Calendar,
|
||||
FileSpreadsheet
|
||||
} from 'lucide-react';
|
||||
import { initialImportLogs } from '../data';
|
||||
import { InventoryItem, OrderItem } from '../types';
|
||||
import {
|
||||
useFiestaStockStatement,
|
||||
useFiestaDeliveries,
|
||||
useFiestaTenantLocations,
|
||||
} from '../services/fiestaQueries';
|
||||
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, num as fnum, str as fstr, ymd } from '../services/fiestaApi';
|
||||
import { stockRowToProduct, stockRowToInventory, mapOrderStatus, shortTime } from '../services/fiestaMappers';
|
||||
|
||||
interface OperationsViewProps {
|
||||
searchQuery: string;
|
||||
isCoimbatoreView: boolean;
|
||||
}
|
||||
|
||||
export default function OperationsView({ searchQuery, isCoimbatoreView }: OperationsViewProps) {
|
||||
// Sub-tabs state
|
||||
const [activeSubTab, setActiveSubTab] = useState<'inventory' | 'catalogue' | 'orders' | 'import'>('inventory');
|
||||
|
||||
// ── Live operations data (Fiesta) ─────────────────────────────────────────
|
||||
const today = new Date();
|
||||
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
|
||||
// Date-range filter for the Orders sub-tab (drives the live deliveries query).
|
||||
const [fromdate, setFromdate] = useState<string>(ymd(monthStart));
|
||||
const [todate, setTodate] = useState<string>(ymd(today));
|
||||
const dayOffset = (n: number) => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - n);
|
||||
return ymd(d);
|
||||
};
|
||||
const datePresets: Array<{ key: string; label: string; from: string; to: string }> = [
|
||||
{ key: 'today', label: 'Today', from: ymd(today), to: ymd(today) },
|
||||
{ key: 'yesterday', label: 'Yesterday', from: dayOffset(1), to: dayOffset(1) },
|
||||
{ key: '7d', label: 'Last 7 Days', from: dayOffset(6), to: ymd(today) },
|
||||
{ key: '30d', label: 'Last 30 Days', from: dayOffset(29), to: ymd(today) },
|
||||
{ key: 'month', label: 'This Month', from: ymd(monthStart), to: ymd(today) },
|
||||
];
|
||||
const activePreset = datePresets.find((p) => p.from === fromdate && p.to === todate)?.key ?? 'custom';
|
||||
|
||||
const locationsQ = useFiestaTenantLocations(FIESTA_TENANT_ID);
|
||||
const primaryLocation =
|
||||
(locationsQ.data ?? []).find((l) => Number(l.locationid) === FIESTA_PRIMARY_LOCATION_ID) ||
|
||||
(locationsQ.data ?? [])[0];
|
||||
const locationId = primaryLocation ? Number(primaryLocation.locationid) : FIESTA_PRIMARY_LOCATION_ID;
|
||||
const locationName = fstr(primaryLocation?.locationname) || 'Primary Outlet';
|
||||
|
||||
const stockQ = useFiestaStockStatement({
|
||||
tenantid: FIESTA_TENANT_ID,
|
||||
locationid: locationId,
|
||||
keyword: '',
|
||||
pageno: 1,
|
||||
pagesize: 100,
|
||||
});
|
||||
const deliveriesQ = useFiestaDeliveries({ tenantid: FIESTA_TENANT_ID, fromdate, todate });
|
||||
|
||||
// Total inventory value = Σ closing × unit cost across the live stock statement.
|
||||
const inventoryValue = (stockQ.data ?? []).reduce(
|
||||
(sum, r) => sum + fnum(r.closing) * fnum(r.productcost),
|
||||
0,
|
||||
);
|
||||
|
||||
// Dynamic state arrays for interaction (seeded from live data once it loads).
|
||||
const [inventoryList, setInventoryList] = useState<InventoryItem[]>([]);
|
||||
const [productList, setProductList] = useState<ReturnType<typeof stockRowToProduct>[]>([]);
|
||||
const [orderList, setOrderList] = useState<OrderItem[]>([]);
|
||||
const [importLogs, setImportLogs] = useState(initialImportLogs);
|
||||
|
||||
useEffect(() => {
|
||||
if (stockQ.data) {
|
||||
setProductList(stockQ.data.map(stockRowToProduct));
|
||||
setInventoryList(stockQ.data.map((r) => stockRowToInventory(r, locationName)));
|
||||
}
|
||||
}, [stockQ.data, locationName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (deliveriesQ.data) {
|
||||
setOrderList(
|
||||
deliveriesQ.data.map((r): OrderItem => {
|
||||
const cust = mapOrderStatus(fstr(r.orderstatus));
|
||||
return {
|
||||
id: fstr(r.orderid) || `DLV-${fstr(r.deliveryid)}`,
|
||||
store: fstr(r.pickupcustomer) || fstr(r.pickuplocation) || `Location ${fstr(r.locationid)}`,
|
||||
amount: fnum(r.deliveryamt) || fnum(r.orderamount),
|
||||
time: shortTime(r.assigntime || r.deliverydate),
|
||||
status: cust === 'DELIVERED' ? 'SHIPPED' : fstr(r.orderstatus).toLowerCase() === 'cancelled' ? 'FLAGGED' : 'PROCESSING',
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, [deliveriesQ.data]);
|
||||
|
||||
// Modal open states
|
||||
const [showAddSkuModal, setShowAddSkuModal] = useState(false);
|
||||
const [showTransferModal, setShowTransferModal] = useState(false);
|
||||
|
||||
// Form states
|
||||
const [newSku, setNewSku] = useState({
|
||||
sku: '',
|
||||
name: '',
|
||||
warehouse: '',
|
||||
stockLevel: 0,
|
||||
maxCapacity: 1000,
|
||||
status: 'Optimal' as 'Optimal' | 'Low Stock' | 'Critical',
|
||||
region: 'CBE-NORTH' as 'CBE-NORTH' | 'CBE-SOUTH' | 'CBE-EAST' | 'CBE-WEST' | 'TIRUPPUR'
|
||||
});
|
||||
|
||||
const [transferData, setTransferData] = useState({
|
||||
sku: '',
|
||||
origin: '',
|
||||
destination: '',
|
||||
quantity: 100
|
||||
});
|
||||
|
||||
// Filter lists based on global Search query
|
||||
const filteredInventory = inventoryList.filter(item =>
|
||||
item.sku.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
item.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
item.warehouse.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const filteredCatalogue = productList.filter(prod =>
|
||||
prod.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
prod.sku.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
prod.category.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const filteredOrders = orderList.filter(ord =>
|
||||
ord.id.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
ord.store.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
// Form submit handles
|
||||
const handleAddSku = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newSku.sku || !newSku.name || !newSku.warehouse) {
|
||||
alert('Please fill out all metadata fields before committing.');
|
||||
return;
|
||||
}
|
||||
|
||||
const itemToAdd: InventoryItem = {
|
||||
sku: newSku.sku,
|
||||
name: newSku.name,
|
||||
warehouse: newSku.warehouse,
|
||||
stockLevel: Number(newSku.stockLevel),
|
||||
maxCapacity: Number(newSku.maxCapacity),
|
||||
status: newSku.stockLevel < 20 ? 'Critical' : newSku.stockLevel < 100 ? 'Low Stock' : 'Optimal',
|
||||
region: newSku.region
|
||||
};
|
||||
|
||||
setInventoryList([itemToAdd, ...inventoryList]);
|
||||
setShowAddSkuModal(false);
|
||||
|
||||
// reset form
|
||||
setNewSku({
|
||||
sku: '',
|
||||
name: '',
|
||||
warehouse: '',
|
||||
stockLevel: 0,
|
||||
maxCapacity: 1000,
|
||||
status: 'Optimal',
|
||||
region: 'CBE-NORTH'
|
||||
});
|
||||
};
|
||||
|
||||
const handleExecuteTransfer = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!transferData.sku || !transferData.origin || !transferData.destination) {
|
||||
alert('Kindly configure transfer coordinates and SKU.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt to update origin stock levels
|
||||
setInventoryList(prev =>
|
||||
prev.map(item => {
|
||||
if (item.sku === transferData.sku) {
|
||||
const newLevel = Math.max(0, item.stockLevel - transferData.quantity);
|
||||
return {
|
||||
...item,
|
||||
stockLevel: newLevel,
|
||||
status: newLevel < 20 ? 'Critical' : newLevel < 100 ? 'Low Stock' : 'Optimal'
|
||||
};
|
||||
}
|
||||
return item;
|
||||
})
|
||||
);
|
||||
|
||||
alert(`Transfer of ${transferData.quantity} units committed successfully. Tracking ID: TRF-${Math.floor(Math.random() * 900000 + 100000)}`);
|
||||
setShowTransferModal(false);
|
||||
};
|
||||
|
||||
const handleToggleProductExposure = (id: string) => {
|
||||
setProductList(prev =>
|
||||
prev.map(p => p.id === id ? { ...p, verified: !p.verified } : p)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-lg animate-in fade-in duration-500">
|
||||
|
||||
{/* Tab Navigation header */}
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end border-b border-[#e2e8f0] pb-px gap-2">
|
||||
<div>
|
||||
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a]">
|
||||
Operations Hub{isCoimbatoreView && ': Coimbatore'}
|
||||
</h1>
|
||||
<p className="text-zinc-500 font-sans text-xs mt-1">
|
||||
Global product assortment, inventory levels, warehouse tracking, and data sync tools.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Dynamic Nav Sub-Tabs */}
|
||||
<nav className="flex gap-lg">
|
||||
{(['inventory', 'catalogue', 'orders', 'import'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveSubTab(tab)}
|
||||
className={`font-sans text-xs font-semibold uppercase tracking-wider pb-2 cursor-pointer transition-colors relative ${
|
||||
activeSubTab === tab
|
||||
? 'text-[#581c87] font-bold'
|
||||
: 'text-zinc-400 hover:text-zinc-600'
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
{activeSubTab === tab && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-[#581c87] animate-in slide-in-from-left-2 duration-100" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Dynamic Display Area based on tabs selection */}
|
||||
{activeSubTab === 'inventory' && (
|
||||
<div className="space-y-lg">
|
||||
{/* Top Key Operational Indicators Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-gutter">
|
||||
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl flex justify-between items-start shadow-sm">
|
||||
<div>
|
||||
<p className="text-[11px] font-sans font-bold text-zinc-500 uppercase tracking-widest">
|
||||
Total Inventory Value
|
||||
</p>
|
||||
<h3 className="font-sans font-bold text-[#0f172a] text-xl mt-xs">
|
||||
₹{inventoryValue.toLocaleString('en-IN', { maximumFractionDigits: 0 })}
|
||||
</h3>
|
||||
<p className="text-[11px] text-emerald-600 font-semibold mt-sm flex items-center gap-1.5">
|
||||
<TrendingUp size={12} />
|
||||
{productList.length} SKUs · {locationName}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-lg bg-purple-50 text-[#581c87]">
|
||||
<DollarSign size={18} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl flex justify-between items-start shadow-sm">
|
||||
<div>
|
||||
<p className="text-[11px] font-sans font-bold text-zinc-500 uppercase tracking-widest">
|
||||
Low Stock Alerts
|
||||
</p>
|
||||
<h3 className="font-sans font-bold text-rose-500 text-xl mt-xs">
|
||||
{inventoryList.filter(item => item.status !== 'Optimal').length} SKUs
|
||||
</h3>
|
||||
<p className="text-[11px] text-zinc-400 mt-sm">Across local regional warehouses</p>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-lg bg-rose-50 text-rose-500">
|
||||
<AlertTriangle size={18} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl flex justify-between items-start shadow-sm">
|
||||
<div>
|
||||
<p className="text-[11px] font-sans font-bold text-zinc-500 uppercase tracking-widest">
|
||||
Fulfillment Health
|
||||
</p>
|
||||
<h3 className="font-sans font-bold text-[#0f172a] text-xl mt-xs">98.4%</h3>
|
||||
<div className="w-40 bg-[#eceef0] h-1.5 rounded-full overflow-hidden mt-sm">
|
||||
<div className="bg-[#581c87] h-full rounded-full" style={{ width: '98.4%' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-lg bg-emerald-50 text-emerald-600 animate-pulse">
|
||||
<PackageCheck size={18} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sub Grid splits: Product state table (3 cols) and Action sidebar panels (1 col) */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-gutter">
|
||||
|
||||
{/* Left table container */}
|
||||
<div className="lg:col-span-3 bg-white border border-[#e2e8f0] rounded-xl overflow-hidden flex flex-col justify-between shadow-sm">
|
||||
<div>
|
||||
<div className="p-md border-b border-[#e2e8f0] flex justify-between items-center bg-[#f8fafc]">
|
||||
<h4 className="font-sans font-bold text-sm text-[#0f172a]">
|
||||
Product Inventory Levels
|
||||
</h4>
|
||||
|
||||
<div className="flex gap-2 text-xs">
|
||||
<span className="text-zinc-500 font-medium">Auto-synced</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left font-sans text-xs">
|
||||
<thead className="bg-[#f8fafc] border-b border-[#e2e8f0] text-zinc-500 text-[10px] uppercase tracking-wider font-semibold">
|
||||
<tr>
|
||||
<th className="p-md">Product SKU</th>
|
||||
<th className="p-md">Warehouse</th>
|
||||
<th className="p-md">Stock Level</th>
|
||||
<th className="p-md">Status</th>
|
||||
<th className="p-md text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#f1f5f9]">
|
||||
{filteredInventory.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center py-10 text-zinc-400">
|
||||
No matching items identified. Try another query or reload.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredInventory.map((item, idx) => {
|
||||
const percentage = (item.stockLevel / item.maxCapacity) * 100;
|
||||
return (
|
||||
<tr key={idx} className="hover:bg-[#f2f4f6]/50 transition-colors">
|
||||
<td className="p-md">
|
||||
<p className="font-bold text-[#0f172a]">{item.sku}</p>
|
||||
<p className="text-[10px] text-zinc-400 font-medium">{item.name}</p>
|
||||
</td>
|
||||
<td className="p-md text-zinc-600 font-medium">
|
||||
<span className="bg-[#f2f4f6] px-1.5 py-0.5 rounded text-[10px] font-mono mr-1">
|
||||
{item.region}
|
||||
</span>
|
||||
{item.warehouse}
|
||||
</td>
|
||||
<td className="p-md">
|
||||
<p className="font-mono font-bold text-zinc-700">{item.stockLevel.toLocaleString()} units</p>
|
||||
<div className="w-24 bg-[#eceef0] h-1 mt-1 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${
|
||||
item.status === 'Critical' ? 'bg-rose-500' : item.status === 'Low Stock' ? 'bg-amber-500' : 'bg-[#581c87]'
|
||||
}`}
|
||||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-md">
|
||||
<span className={`px-2 py-0.5 rounded-full text-[10px] uppercase font-bold tracking-tight inline-block ${
|
||||
item.status === 'Critical'
|
||||
? 'bg-rose-100 text-rose-750'
|
||||
: item.status === 'Low Stock'
|
||||
? 'bg-amber-100 text-amber-750'
|
||||
: 'bg-emerald-100 text-emerald-750'
|
||||
}`}>
|
||||
{item.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-md text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setTransferData({
|
||||
sku: item.sku,
|
||||
origin: item.warehouse,
|
||||
destination: '',
|
||||
quantity: 50
|
||||
});
|
||||
setShowTransferModal(true);
|
||||
}}
|
||||
className="text-xs font-semibold text-[#581c87] hover:underline cursor-pointer"
|
||||
>
|
||||
Transfer
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-md border-t border-[#f1f5f9] bg-[#f8fafc] flex justify-between items-center text-[10px] text-zinc-500 font-medium font-sans">
|
||||
<span>Displaying 1-{filteredInventory.length} of {inventoryList.length} global items</span>
|
||||
<span>Active Ledger Nodes Online</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right sidebar quick tasks */}
|
||||
<div className="space-y-gutter">
|
||||
{/* Forecast module visual details */}
|
||||
<div className="bg-[#0f172a] text-white p-6 rounded-xl relative overflow-hidden flex flex-col justify-between shadow-md h-48">
|
||||
<div>
|
||||
<span className="text-[10px] tracking-wider font-bold opacity-60 uppercase">
|
||||
Forecast Efficiency
|
||||
</span>
|
||||
<p className="font-sans font-bold text-3xl mt-sm">92%</p>
|
||||
<p className="text-zinc-300 text-xs mt-sm leading-relaxed">
|
||||
AI-Driven automated replenishment is saving an estimated ₹1.9L/week in system overstock costs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Embedded SVG graphic visual */}
|
||||
<div className="absolute right-3 bottom-3 opacity-15">
|
||||
<PackageCheck size={64} className="text-purple-300" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions buttons block */}
|
||||
<div className="bg-white border border-[#e2e8f0] rounded-xl p-md shadow-sm">
|
||||
<span className="text-[10px] font-sans font-bold text-zinc-400 uppercase tracking-widest block pb-xs mb-md border-b border-[#f1f5f9]">
|
||||
Quick Actions Ledger
|
||||
</span>
|
||||
|
||||
<div className="grid grid-cols-2 gap-sm">
|
||||
<button
|
||||
onClick={() => setShowAddSkuModal(true)}
|
||||
className="bg-[#f8fafc] border border-[#e2e8f0] p-sm rounded-lg flex flex-col items-center gap-xs text-zinc-700 hover:bg-[#faf5ff] hover:text-[#581c87] hover:border-[#581c87] transition-all cursor-pointer group"
|
||||
>
|
||||
<PlusSquare size={16} className="text-zinc-400 group-hover:text-[#581c87]" />
|
||||
<span className="text-[10px] font-sans font-bold uppercase">Add SKU</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setTransferData({ sku: 'PRO-9920-X1', origin: 'RS Puram Hub (CBE-01)', destination: '', quantity: 100 });
|
||||
setShowTransferModal(true);
|
||||
}}
|
||||
className="bg-[#f8fafc] border border-[#e2e8f0] p-sm rounded-lg flex flex-col items-center gap-xs text-zinc-700 hover:bg-[#faf5ff] hover:text-[#581c87] hover:border-[#581c87] transition-all cursor-pointer group"
|
||||
>
|
||||
<ArrowRightLeft size={16} className="text-zinc-400 group-hover:text-[#581c87]" />
|
||||
<span className="text-[10px] font-sans font-bold uppercase">Transfer</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
const amount = prompt('Enter return item SKU code:');
|
||||
if (amount) {
|
||||
alert(`Returns logged successfully for target SKU code ${amount}. Waiting for physical hub clearance inspection.`);
|
||||
}
|
||||
}}
|
||||
className="bg-[#f8fafc] border border-[#e2e8f0] p-sm rounded-lg flex flex-col items-center gap-xs text-zinc-700 hover:bg-[#faf5ff] hover:text-[#581c87] hover:border-[#581c87] transition-all cursor-pointer group"
|
||||
>
|
||||
<XCircle size={16} className="text-zinc-400 group-hover:text-[#581c87]" />
|
||||
<span className="text-[10px] font-sans font-bold uppercase">Returns</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
alert('Generating automated physical audit compliance report draft sheets... Download started background.');
|
||||
}}
|
||||
className="bg-[#f8fafc] border border-[#e2e8f0] p-sm rounded-lg flex flex-col items-center gap-xs text-zinc-700 hover:bg-[#faf5ff] hover:text-[#581c87] hover:border-[#581c87] transition-all cursor-pointer group"
|
||||
>
|
||||
<FolderSync size={16} className="text-zinc-400 group-hover:text-[#581c87]" />
|
||||
<span className="text-[10px] font-sans font-bold uppercase">Audit CSV</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSubTab === 'catalogue' && (
|
||||
<div className="space-y-md animate-in slide-in-from-right-5">
|
||||
<div className="flex justify-between items-end bg-[#f8fafc] border border-[#e2e8f0] p-md rounded-xl shadow-sm">
|
||||
<div>
|
||||
<h3 className="font-sans font-bold text-sm text-[#0f172a]">
|
||||
Master Assortment Catalogue
|
||||
</h3>
|
||||
<p className="text-zinc-500 text-xs font-sans mt-0.5">
|
||||
Global inventory master list and exposure levels across 4,200 nodes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
const title = prompt('Enter product brand title:');
|
||||
const sku = prompt('Enter SKU catalog code:');
|
||||
const category = prompt('Enter SKU Category:');
|
||||
if (title && sku && category) {
|
||||
setProductList(prev => [
|
||||
...prev,
|
||||
{
|
||||
id: String(prev.length + 1),
|
||||
name: title,
|
||||
sku: sku,
|
||||
unitsSold: 0,
|
||||
revenue: 0,
|
||||
stockStatus: 'Healthy',
|
||||
trend: 'flat',
|
||||
image: 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=150&q=80',
|
||||
category: category,
|
||||
exposure: '0/120 Stores',
|
||||
verified: false
|
||||
}
|
||||
]);
|
||||
alert(`${title} added successfully to unreleased draft portfolio.`);
|
||||
}
|
||||
}}
|
||||
className="bg-[#581c87] hover:bg-purple-800 active:bg-purple-900 text-white font-sans text-xs font-semibold px-4 py-2 rounded-lg cursor-pointer transition-colors shadow-sm"
|
||||
>
|
||||
Add Brand Product
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-[#e2e8f0] rounded-xl overflow-hidden shadow-sm">
|
||||
<table className="w-full text-left font-sans text-xs">
|
||||
<thead className="bg-[#f8fafc] border-b border-[#e2e8f0] text-zinc-500 text-[10px] uppercase font-bold tracking-wider">
|
||||
<tr>
|
||||
<th className="p-md">Product Details</th>
|
||||
<th className="p-md">Category segment</th>
|
||||
<th className="p-md">Verification status</th>
|
||||
<th className="p-md">Store exposure</th>
|
||||
<th className="p-md text-right">Exposure toggle</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#f1f5f9]">
|
||||
{filteredCatalogue.map((prod) => (
|
||||
<tr key={prod.id} className="hover:bg-[#f2f4f6]/50 transition-colors">
|
||||
<td className="p-md flex items-center gap-md">
|
||||
<div className="w-10 h-10 rounded-lg border border-[#e2e8f0] overflow-hidden shrink-0 bg-zinc-50">
|
||||
<img
|
||||
src={prod.image}
|
||||
alt={prod.name}
|
||||
referrerPolicy="no-referrer"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-[#0f172a]">{prod.name}</p>
|
||||
<p className="text-[10px] text-zinc-400 font-mono">SKU: {prod.sku}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-md text-zinc-600 font-medium">{prod.category}</td>
|
||||
<td className="p-md">
|
||||
{prod.verified ? (
|
||||
<span className="flex items-center gap-1.5 text-emerald-600 font-semibold tracking-tight text-[11px]">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500"></span> Verified Portfolio
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5 text-zinc-400 font-medium tracking-tight text-[11px]">
|
||||
<span className="w-2 h-2 rounded-full bg-zinc-300"></span> Under Inspection
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-md text-zinc-500 font-medium">{prod.exposure}</td>
|
||||
<td className="p-md text-right">
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={prod.verified}
|
||||
onChange={() => handleToggleProductExposure(prod.id)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 bg-zinc-200 rounded-full peer peer-focus:ring-0 peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-zinc-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-[#581c87]"></div>
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSubTab === 'orders' && (
|
||||
<div className="space-y-md animate-in slide-in-from-right-5">
|
||||
{/* Day-wise date filter — drives the live deliveries/orders query */}
|
||||
<div className="bg-white border border-[#e2e8f0] rounded-xl p-md shadow-sm flex flex-col lg:flex-row lg:items-center justify-between gap-md">
|
||||
<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 pr-1">
|
||||
<Calendar size={13} className="text-[#581c87]" /> View
|
||||
</span>
|
||||
{datePresets.map((p) => (
|
||||
<button
|
||||
key={p.key}
|
||||
onClick={() => { setFromdate(p.from); setTodate(p.to); }}
|
||||
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold transition-all border cursor-pointer ${
|
||||
activePreset === p.key
|
||||
? 'bg-[#581c87] text-white border-[#581c87] shadow-sm'
|
||||
: 'bg-white text-zinc-600 border-[#e2e8f0] hover:bg-zinc-50'
|
||||
}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-sm text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label className="text-[10px] font-bold text-zinc-400 uppercase tracking-wider">From</label>
|
||||
<input
|
||||
type="date"
|
||||
value={fromdate}
|
||||
max={todate}
|
||||
onChange={(e) => setFromdate(e.target.value)}
|
||||
className="border border-[#e2e8f0] rounded-lg px-2 py-1.5 text-zinc-700 font-medium outline-none focus:ring-1 focus:ring-[#581c87] cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-zinc-300">→</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label className="text-[10px] font-bold text-zinc-400 uppercase tracking-wider">To</label>
|
||||
<input
|
||||
type="date"
|
||||
value={todate}
|
||||
min={fromdate}
|
||||
max={ymd(today)}
|
||||
onChange={(e) => setTodate(e.target.value)}
|
||||
className="border border-[#e2e8f0] rounded-lg px-2 py-1.5 text-zinc-700 font-medium outline-none focus:ring-1 focus:ring-[#581c87] cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-[#e2e8f0] rounded-xl overflow-hidden shadow-sm">
|
||||
<div className="p-md border-b border-[#e2e8f0] bg-[#f8fafc] flex justify-between items-center">
|
||||
<h4 className="font-sans font-bold text-sm text-[#0f172a]">
|
||||
Orders ({filteredOrders.length})
|
||||
</h4>
|
||||
<span className="text-[10px] text-zinc-400 font-medium uppercase tracking-wider">
|
||||
{fromdate === todate ? fromdate : `${fromdate} → ${todate}`}
|
||||
</span>
|
||||
</div>
|
||||
<table className="w-full text-left font-sans text-xs">
|
||||
<thead className="bg-[#f8fafc] border-b border-[#e2e8f0] text-zinc-500 text-[10px] uppercase font-bold tracking-wider">
|
||||
<tr>
|
||||
<th className="p-md">Order ID</th>
|
||||
<th className="p-md">Origin Store Terminal</th>
|
||||
<th className="p-md">Invoice Amount</th>
|
||||
<th className="p-md">Committed Time (IST)</th>
|
||||
<th className="p-md">System state status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#f1f5f9]">
|
||||
{filteredOrders.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center py-10 text-zinc-400">
|
||||
{deliveriesQ.isLoading ? 'Loading live orders…' : 'No orders in this date range.'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredOrders.map((ord) => (
|
||||
<tr key={ord.id} className="hover:bg-[#f2f4f6]/50 transition-colors">
|
||||
<td className="p-md font-mono font-bold text-[#581c87]">{ord.id}</td>
|
||||
<td className="p-md text-[#0f172a] font-medium">{ord.store}</td>
|
||||
<td className="p-md font-mono font-bold text-zinc-700">₹{ord.amount.toLocaleString()}</td>
|
||||
<td className="p-md text-zinc-500 font-medium">{ord.time}</td>
|
||||
<td className="p-md">
|
||||
<span className={`px-2 py-0.5 rounded text-[9px] font-bold tracking-wider ${
|
||||
ord.status === 'SHIPPED'
|
||||
? 'bg-purple-100 text-[#581c87] border border-purple-200'
|
||||
: ord.status === 'FLAGGED'
|
||||
? 'bg-rose-100 text-rose-700 border border-rose-200'
|
||||
: 'bg-zinc-100 text-zinc-650 border border-zinc-200'
|
||||
}`}>
|
||||
{ord.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSubTab === 'import' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-gutter animate-in slide-in-from-right-5">
|
||||
{/* Upload panel zone */}
|
||||
<div
|
||||
onClick={() => {
|
||||
const fileRef = prompt('Enter CSV filename representation path:');
|
||||
if (fileRef) {
|
||||
const logsToAdd = {
|
||||
timestamp: 'Just now',
|
||||
batchRef: `#IMP_0922_${String.fromCharCode(65 + Math.floor(Math.random() * 26))}`,
|
||||
type: 'Inventory Sync',
|
||||
source: fileRef,
|
||||
result: `SUCCESS (98 Rows verified)`,
|
||||
status: 'SUCCESS' as const
|
||||
};
|
||||
setImportLogs([logsToAdd, ...importLogs]);
|
||||
alert('Uploaded successfully. Metadata schema verification committed.');
|
||||
}
|
||||
}}
|
||||
className="bg-white border-2 border-dashed border-zinc-300 rounded-xl p-xl flex flex-col items-center justify-center text-center cursor-pointer hover:bg-[#faf5ff]/30 hover:border-[#581c87] transition-all duration-200"
|
||||
>
|
||||
<div className="h-14 w-14 bg-purple-50 text-[#581c87] rounded-full flex items-center justify-center mb-md shadow-sm">
|
||||
<UploadCloud size={24} />
|
||||
</div>
|
||||
|
||||
<h4 className="font-sans font-bold text-base text-[#0f172a]">
|
||||
Upload Inventory CSV
|
||||
</h4>
|
||||
|
||||
<p className="text-zinc-500 text-xs mt-2 max-w-[20rem] leading-relaxed">
|
||||
Drag and drop your .csv, .xlsx or .xml sheets here to automatically update global stock balances or master portfolios schemas.
|
||||
</p>
|
||||
|
||||
<div className="mt-lg flex gap-md">
|
||||
<button className="bg-[#0f172a] text-white text-xs font-semibold px-4 py-2 rounded-lg cursor-pointer hover:bg-zinc-800 transition-colors shadow-sm">
|
||||
Browse Files
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
alert('Template documentation initiated.');
|
||||
}}
|
||||
className="bg-white border border-[#e2e8f0] text-zinc-600 text-xs px-4 py-2 rounded-lg cursor-pointer hover:bg-zinc-50 transition-colors"
|
||||
>
|
||||
Template CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validation indicators checker logs split */}
|
||||
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl flex flex-col justify-between shadow-sm">
|
||||
<div>
|
||||
<span className="text-[10px] font-sans font-bold text-zinc-400 uppercase tracking-widest block pb-xs mb-md border-b border-[#f1f5f9]">
|
||||
Interactive Schema Validator
|
||||
</span>
|
||||
|
||||
<div className="space-y-sm">
|
||||
<div className="p-sm bg-emerald-50/50 border border-emerald-100 rounded-xl flex gap-sm items-start text-xs">
|
||||
<FileCheck size={16} className="text-emerald-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h5 className="font-bold text-[#0f172a]">Verification Rule Passed</h5>
|
||||
<p className="text-zinc-600 mt-0.5">Primary header nodes align perfectly with Master specification v2.8.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-sm bg-rose-50/50 border border-rose-100 rounded-xl flex gap-sm items-start text-xs">
|
||||
<AlertOctagon size={16} className="text-rose-500 shrink-0 mt-bar" />
|
||||
<div>
|
||||
<h5 className="font-bold text-[#0f172a]">14 Duplicate SKUs Detected</h5>
|
||||
<p className="text-zinc-600 mt-0.5">Duplicate item overlaps flagged inside columns 45, 82. Verify manual index configurations before finalizing commit.</p>
|
||||
<button
|
||||
onClick={() => alert('Downloading conflicts summary report...')}
|
||||
className="text-rose-600 font-bold hover:underline mt-sm block"
|
||||
>
|
||||
DOWNLOAD RESOLUTION LOG
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs table list */}
|
||||
<div className="mt-md pt-md border-t border-[#f1f5f9]">
|
||||
<span className="text-[10px] font-sans font-bold text-zinc-400 uppercase tracking-wider block mb-sm">Recent Import Logs</span>
|
||||
|
||||
<div className="space-y-1 max-h-36 overflow-y-auto text-xs">
|
||||
{importLogs.map((log, i) => (
|
||||
<div key={i} className="flex justify-between items-center p-2 bg-[#f8fafc] border border-[#e2e8f0]/40 rounded-lg hover:bg-[#faf5ff]/20 transition-colors">
|
||||
<div>
|
||||
<p className="font-mono text-[10px] font-bold text-[#581c87]">{log.batchRef}</p>
|
||||
<p className="text-[9px] text-zinc-400 font-medium">{log.timestamp} • {log.source}</p>
|
||||
</div>
|
||||
<span className={`text-[9px] font-bold px-1.5 py-0.5 rounded ${
|
||||
log.status === 'SUCCESS' ? 'text-emerald-700 bg-emerald-100' : 'text-rose-700 bg-rose-100'
|
||||
}`}>
|
||||
{log.result}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MODAL 1: ADD SKU MODAL */}
|
||||
{showAddSkuModal && (
|
||||
<div className="fixed inset-0 bg-[#0f172a]/40 backdrop-blur-xs z-[150] flex items-center justify-center p-md animate-in fade-in duration-150">
|
||||
<div className="bg-white border border-[#e2e8f0] rounded-xl w-full max-w-[28rem] shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200">
|
||||
<div className="p-md border-b border-[#e2e8f0] bg-[#f8fafc] flex justify-between items-center">
|
||||
<h4 className="font-sans font-bold text-sm text-[#0f172a]">
|
||||
Add Item SKU to Balance Ledger
|
||||
</h4>
|
||||
<button
|
||||
onClick={() => setShowAddSkuModal(false)}
|
||||
className="p-1.5 hover:bg-zinc-200 rounded-full text-zinc-400 cursor-pointer text-left"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleAddSku} className="p-md space-y-md text-xs">
|
||||
<div className="grid grid-cols-2 gap-sm">
|
||||
<div className="space-y-1">
|
||||
<label className="font-bold text-zinc-500 uppercase tracking-widest block text-[9px]">SKU Code (*)</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g., SKU-1290-A"
|
||||
value={newSku.sku}
|
||||
onChange={(e) => setNewSku({ ...newSku, sku: e.target.value })}
|
||||
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f1f5f9] focus:bg-white outline-none focus:ring-1 focus:ring-[#581c87]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="font-bold text-zinc-500 uppercase tracking-widest block text-[9px]">Product Name</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g., Thermal Printer"
|
||||
value={newSku.name}
|
||||
onChange={(e) => setNewSku({ ...newSku, name: e.target.value })}
|
||||
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f1f5f9] focus:bg-white outline-none focus:ring-1 focus:ring-[#581c87]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="font-bold text-zinc-500 uppercase tracking-widest block text-[9px]">Target Warehouse</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g., Coimbatore main logistics CBE"
|
||||
value={newSku.warehouse}
|
||||
onChange={(e) => setNewSku({ ...newSku, warehouse: e.target.value })}
|
||||
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f1f5f9] focus:bg-white outline-none focus:ring-1 focus:ring-[#581c87]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-sm">
|
||||
<div className="space-y-1">
|
||||
<label className="font-bold text-zinc-500 uppercase tracking-widest block text-[9px]">Initial Balance</label>
|
||||
<input
|
||||
type="number"
|
||||
value={newSku.stockLevel}
|
||||
onChange={(e) => setNewSku({ ...newSku, stockLevel: Number(e.target.value) })}
|
||||
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f1f5f9] focus:bg-white outline-none focus:ring-1 focus:ring-[#581c87]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="font-bold text-zinc-500 uppercase tracking-widest block text-[9px]">Warehouse Region</label>
|
||||
<select
|
||||
value={newSku.region}
|
||||
onChange={(e) => setNewSku({ ...newSku, region: e.target.value as any })}
|
||||
className="w-full border border-[#e2e8f0] rounded-lg p-2 bg-[#f1f5f9] focus:bg-white outline-none focus:ring-1 focus:ring-[#581c87]"
|
||||
>
|
||||
<option value="CBE-NORTH">Coimbatore North (CBE-NORTH)</option>
|
||||
<option value="CBE-SOUTH">Coimbatore South (CBE-SOUTH)</option>
|
||||
<option value="CBE-EAST">Coimbatore East (CBE-EAST)</option>
|
||||
<option value="CBE-WEST">Coimbatore West (CBE-WEST)</option>
|
||||
<option value="TIRUPPUR">Tiruppur Regional Hub (TIRUPPUR)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-sm border-t border-[#f1f5f9] flex justify-end gap-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddSkuModal(false)}
|
||||
className="px-4 py-2 border border-[#e2e8f0] rounded-lg font-semibold text-zinc-500 hover:bg-zinc-50 cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-[#581c87] text-white rounded-lg font-semibold hover:bg-purple-800 cursor-pointer shadow-sm"
|
||||
>
|
||||
Commit Ledger SKU
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MODAL 2: TRANSFER STOCK MODAL */}
|
||||
{showTransferModal && (
|
||||
<div className="fixed inset-0 bg-[#0f172a]/40 backdrop-blur-xs z-[150] flex items-center justify-center p-md animate-in fade-in duration-150">
|
||||
<div className="bg-white border border-[#e2e8f0] rounded-xl w-full max-w-[24rem] shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200">
|
||||
<div className="p-md border-b border-[#e2e8f0] bg-[#f8fafc] flex justify-between items-center">
|
||||
<h4 className="font-sans font-bold text-sm text-[#0f172a]">
|
||||
Request Node Stock Transfer
|
||||
</h4>
|
||||
<button
|
||||
onClick={() => setShowTransferModal(false)}
|
||||
className="p-1 hover:bg-zinc-200 rounded-full text-zinc-400 cursor-pointer"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleExecuteTransfer} className="p-md space-y-md text-xs">
|
||||
<div className="space-y-1">
|
||||
<label className="font-bold text-zinc-500 uppercase tracking-widest block text-[9px]">TRANSFERRING SKU</label>
|
||||
<input
|
||||
type="text"
|
||||
value={transferData.sku}
|
||||
onChange={(e) => setTransferData({ ...transferData, sku: e.target.value })}
|
||||
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f1f5f9] font-mono focus:bg-white outline-none focus:ring-1 focus:ring-[#581c87]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="font-bold text-zinc-500 uppercase tracking-widest block text-[9px]">ORIGIN WAREHOUSE</label>
|
||||
<input
|
||||
type="text"
|
||||
value={transferData.origin}
|
||||
onChange={(e) => setTransferData({ ...transferData, origin: e.target.value })}
|
||||
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f1f5f9] focus:bg-white outline-none text-zinc-800 font-medium focus:ring-1 focus:ring-[#581c87]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="font-bold text-zinc-500 uppercase tracking-widest block text-[9px]">TARGET DESTINATION WAREHOUSE</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g., Coimbatore South CBE-03"
|
||||
value={transferData.destination}
|
||||
onChange={(e) => setTransferData({ ...transferData, destination: e.target.value })}
|
||||
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f1f5f9] focus:bg-white outline-none focus:ring-1 focus:ring-[#581c87]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="font-bold text-zinc-500 uppercase tracking-widest block text-[9px]">TRANSFER QUANTITY</label>
|
||||
<input
|
||||
type="number"
|
||||
value={transferData.quantity}
|
||||
onChange={(e) => setTransferData({ ...transferData, quantity: Number(e.target.value) })}
|
||||
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f1f5f9] focus:bg-white outline-none focus:ring-1 focus:ring-[#581c87]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-sm border-t border-[#f1f5f9] flex justify-end gap-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTransferModal(false)}
|
||||
className="px-4 py-2 border border-[#e2e8f0] rounded-lg font-semibold text-zinc-500 hover:bg-zinc-50 cursor-pointer"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-[#581c87] text-white rounded-lg font-semibold hover:bg-purple-800 cursor-pointer shadow-sm"
|
||||
>
|
||||
Approve Routing
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
749
src/components/OrdersDeliveriesView.tsx
Normal file
749
src/components/OrdersDeliveriesView.tsx
Normal file
@@ -0,0 +1,749 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
ShoppingBag,
|
||||
Truck,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
UserCheck,
|
||||
MapPin,
|
||||
TrendingUp,
|
||||
Plus,
|
||||
ChevronRight,
|
||||
Package,
|
||||
ArrowRight,
|
||||
AlertCircle,
|
||||
Clock4,
|
||||
Search,
|
||||
Check,
|
||||
Calendar,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import { CustomerOrder } from '../types';
|
||||
import {
|
||||
useFiestaDeliveries,
|
||||
useFiestaDeliverySummary,
|
||||
useFiestaRiders,
|
||||
} from '../services/fiestaQueries';
|
||||
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd } from '../services/fiestaApi';
|
||||
import { deliveryRowToOrder } from '../services/fiestaMappers';
|
||||
|
||||
interface OrdersDeliveriesViewProps {
|
||||
searchQuery?: string;
|
||||
isCoimbatoreView?: boolean;
|
||||
locationid?: number;
|
||||
}
|
||||
|
||||
interface DeliveryExecutive {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
status: 'Active Duty' | 'Idle' | 'Offline';
|
||||
rating: number;
|
||||
completedToday: number;
|
||||
currentZone: string;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
const RIDER_AVATARS = [
|
||||
'https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=150&q=80',
|
||||
'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=150&q=80',
|
||||
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=150&q=80',
|
||||
];
|
||||
|
||||
function riderRowToExecutive(row: Record<string, unknown>, idx: number): DeliveryExecutive {
|
||||
return {
|
||||
id: `DE-${fstr(row.userid) || idx}`,
|
||||
name: fstr(row.fullname) || `${fstr(row.firstname)} ${fstr(row.lastname)}`.trim() || 'Rider',
|
||||
phone: fstr(row.contactno) || '—',
|
||||
status: fstr(row.starttime) ? 'Active Duty' : 'Idle',
|
||||
rating: 4.7,
|
||||
completedToday: fnum(row.completed) || fnum(row.deliverycount),
|
||||
currentZone: fstr(row.city) || fstr(row.vehiclename) || fstr(row.vehicleno) || 'Coimbatore',
|
||||
avatar: RIDER_AVATARS[idx % RIDER_AVATARS.length],
|
||||
};
|
||||
}
|
||||
|
||||
export default function OrdersDeliveriesView({ searchQuery = '', isCoimbatoreView = false, locationid }: OrdersDeliveriesViewProps) {
|
||||
// ── Live deliveries / fleet (Fiesta) ──────────────────────────────────────
|
||||
// Order feed + dispatch controls run off the live deliveries board; the KPI
|
||||
// strip uses the delivery summary; the fleet panel uses the active riders.
|
||||
// A date-range filter lets the user view orders/deliveries day-wise.
|
||||
const today = new Date();
|
||||
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
const [fromdate, setFromdate] = useState<string>(ymd(monthStart));
|
||||
const [todate, setTodate] = useState<string>(ymd(today));
|
||||
|
||||
// Quick-range presets (computed off the current day; no Date.now in render path).
|
||||
const dayOffset = (n: number) => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - n);
|
||||
return ymd(d);
|
||||
};
|
||||
const presets: Array<{ key: string; label: string; from: string; to: string }> = [
|
||||
{ key: 'today', label: 'Today', from: ymd(today), to: ymd(today) },
|
||||
{ key: 'yesterday', label: 'Yesterday', from: dayOffset(1), to: dayOffset(1) },
|
||||
{ key: '7d', label: 'Last 7 Days', from: dayOffset(6), to: ymd(today) },
|
||||
{ key: '30d', label: 'Last 30 Days', from: dayOffset(29), to: ymd(today) },
|
||||
{ key: 'month', label: 'This Month', from: ymd(monthStart), to: ymd(today) },
|
||||
];
|
||||
const activePreset = presets.find((p) => p.from === fromdate && p.to === todate)?.key ?? 'custom';
|
||||
|
||||
const deliveriesQ = useFiestaDeliveries({ tenantid: FIESTA_TENANT_ID, fromdate, todate });
|
||||
const summaryQ = useFiestaDeliverySummary({ tenantid: FIESTA_TENANT_ID, fromdate, todate });
|
||||
const ridersQ = useFiestaRiders({ tenantid: FIESTA_TENANT_ID });
|
||||
|
||||
const [orders, setOrders] = useState<CustomerOrder[]>([]);
|
||||
const [executives, setExecutives] = useState<DeliveryExecutive[]>([]);
|
||||
const [selectedOrder, setSelectedOrder] = useState<CustomerOrder | null>(null);
|
||||
const [filterStatus, setFilterStatus] = useState<string>('ALL');
|
||||
const [localSearch, setLocalSearch] = useState('');
|
||||
|
||||
// Seed local state once live data arrives so existing dispatch/create handlers
|
||||
// continue to mutate in-session.
|
||||
useEffect(() => {
|
||||
if (deliveriesQ.data) {
|
||||
const mapped = deliveriesQ.data.map(deliveryRowToOrder);
|
||||
setOrders(mapped);
|
||||
// Keep the current selection only if it's still in the new range; otherwise
|
||||
// fall back to the first order so the detail panel stays in sync.
|
||||
setSelectedOrder((prev) =>
|
||||
(prev && mapped.some((o) => o.id === prev.id)) ? prev : mapped[0] ?? null,
|
||||
);
|
||||
}
|
||||
}, [deliveriesQ.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ridersQ.data) setExecutives(ridersQ.data.map(riderRowToExecutive));
|
||||
}, [ridersQ.data]);
|
||||
|
||||
const summary = summaryQ.data;
|
||||
|
||||
// Local filtered list of orders
|
||||
const storeOrders = locationid ? orders.filter(o => o.locationid === locationid) : orders;
|
||||
|
||||
const filteredOrdersList = storeOrders.filter(o => {
|
||||
const term = (localSearch || searchQuery).toLowerCase();
|
||||
const matchesSearch = o.id.toLowerCase().includes(term) ||
|
||||
o.customerName.toLowerCase().includes(term) ||
|
||||
o.address.toLowerCase().includes(term);
|
||||
const matchesFilter = filterStatus === 'ALL' || o.status === filterStatus;
|
||||
return matchesSearch && matchesFilter;
|
||||
});
|
||||
|
||||
// Calculate dynamic stats for metrics cards based on filtered storeOrders
|
||||
const totalDeliveriesCount = storeOrders.length;
|
||||
const pendingFulfillmentCount = storeOrders.filter(o => o.status === 'PROCESSING' || o.status === 'CONFIRMED').length;
|
||||
const activeDispatchCount = storeOrders.filter(o => o.status === 'OUT_FOR_DELIVERY').length;
|
||||
const completedDeliveriesCount = storeOrders.filter(o => o.status === 'DELIVERED').length;
|
||||
|
||||
const MOCK_NAMES = ['Aravind Swamy', 'Karthik Raja', 'Priya Mani', 'Meera Jasmine', 'Sanjay Dutt', 'Divya Spandana', 'Vijay Sethupathi', 'Nayan Thara'];
|
||||
const MOCK_STREETS = ['Avarampalayam Rd', 'DB Road', 'Cross Cut Road', 'Avinashi Road', 'Trichy Road', 'NSR Road', 'Sathy Road', 'Marudhamalai Road'];
|
||||
const MOCK_ITEMS = [
|
||||
{ name: 'Tata Salt Premium Iodized 1kg', price: 28 },
|
||||
{ name: 'Gold Winner Sunflower Oil 1L', price: 145 },
|
||||
{ name: 'Britannia Marie Gold Biscuit 250g', price: 35 },
|
||||
{ name: 'MTR Sambar Powder 200g', price: 85 },
|
||||
{ name: 'Aavin Salted Butter 500g', price: 260 },
|
||||
{ name: 'Ponni Boiled Rice 5kg', price: 380 },
|
||||
{ name: 'Fresh Ooty Carrots 500g', price: 45 },
|
||||
{ name: 'Nescafe Classic Coffee 100g', price: 185 },
|
||||
];
|
||||
|
||||
const handleCreateMockOrder = () => {
|
||||
const randomName = MOCK_NAMES[Math.floor(Math.random() * MOCK_NAMES.length)];
|
||||
const randomStreet = MOCK_STREETS[Math.floor(Math.random() * MOCK_STREETS.length)];
|
||||
const numItems = Math.floor(Math.random() * 3) + 1; // 1 to 3 items
|
||||
const selectedItems = [];
|
||||
let amount = 0;
|
||||
for (let k = 0; k < numItems; k++) {
|
||||
const it = MOCK_ITEMS[Math.floor(Math.random() * MOCK_ITEMS.length)];
|
||||
const qty = Math.floor(Math.random() * 2) + 1;
|
||||
selectedItems.push({ name: it.name, quantity: qty, price: it.price });
|
||||
amount += it.price * qty;
|
||||
}
|
||||
const newId = `ORD-${Math.floor(100000 + Math.random() * 900000)}`;
|
||||
const newOrder: CustomerOrder = {
|
||||
id: newId,
|
||||
customerName: randomName,
|
||||
phone: `9${Math.floor(100000000 + Math.random() * 900000000)}`,
|
||||
address: `${Math.floor(10 + Math.random() * 190)}, ${randomStreet}, Coimbatore`,
|
||||
items: selectedItems,
|
||||
amount,
|
||||
time: new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }),
|
||||
status: 'PROCESSING',
|
||||
assignedRider: 'Pending Assignment',
|
||||
hub: locationid ? `Outlet Node #${locationid}` : 'Coimbatore Hub',
|
||||
locationid: locationid ?? 1097,
|
||||
};
|
||||
setOrders(prev => [newOrder, ...prev]);
|
||||
setSelectedOrder(newOrder);
|
||||
};
|
||||
|
||||
const handleUpdateStatus = (newStatus: CustomerOrder['status']) => {
|
||||
if (!selectedOrder) return;
|
||||
setOrders(prev => prev.map(o => {
|
||||
if (o.id === selectedOrder.id) {
|
||||
const updated = { ...o, status: newStatus };
|
||||
setSelectedOrder(updated);
|
||||
return updated;
|
||||
}
|
||||
return o;
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAssignRider = (riderName: string) => {
|
||||
if (!selectedOrder) return;
|
||||
setOrders(prev => prev.map(o => {
|
||||
if (o.id === selectedOrder.id) {
|
||||
const updated = {
|
||||
...o,
|
||||
assignedRider: riderName,
|
||||
status: o.status === 'PROCESSING' ? 'CONFIRMED' : o.status
|
||||
};
|
||||
setSelectedOrder(updated);
|
||||
return updated;
|
||||
}
|
||||
return o;
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-lg animate-in fade-in duration-500">
|
||||
|
||||
{/* View Header with Statistics Overview */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-md border-b border-[#e2e8f0] pb-xl">
|
||||
<div>
|
||||
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a]">
|
||||
Orders & Delivery Operations
|
||||
</h1>
|
||||
<p className="text-zinc-500 font-sans text-xs mt-1">
|
||||
Real-time tracking of app orders, dispatch queues, and active delivery partners across Coimbatore regional sub-hubs.
|
||||
</p>
|
||||
<div className="mt-1.5">
|
||||
{deliveriesQ.isLoading ? (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-zinc-400 uppercase tracking-wide">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-zinc-300 animate-pulse" /> Loading live deliveries…
|
||||
</span>
|
||||
) : deliveriesQ.isError ? (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-rose-600 uppercase tracking-wide">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-rose-500" /> Live data unavailable
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-emerald-600 uppercase tracking-wide">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" /> Live · {orders.length} deliveries · {executives.length} riders
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Level Delivery Performance Indicators */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-gutter font-sans">
|
||||
|
||||
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl flex items-center gap-md shadow-sm">
|
||||
<div className="p-2 bg-purple-50 text-[#581c87] rounded-lg">
|
||||
<ShoppingBag size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] text-zinc-400 uppercase tracking-wider font-bold">Deliveries in Range</p>
|
||||
<p className="font-sans font-bold text-lg text-zinc-800">{totalDeliveriesCount.toLocaleString('en-IN')} total</p>
|
||||
<p className="text-[10px] text-emerald-600 font-semibold mt-0.5">{fromdate === todate ? fromdate : `${fromdate} → ${todate}`}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl flex items-center gap-md shadow-sm">
|
||||
<div className="p-2 bg-amber-50 text-amber-600 rounded-lg">
|
||||
<Clock size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] text-zinc-400 uppercase tracking-wider font-bold">Pending Fulfilment</p>
|
||||
<p className="font-sans font-bold text-lg text-zinc-800">
|
||||
{pendingFulfillmentCount + activeDispatchCount} active
|
||||
</p>
|
||||
<p className="text-[10px] text-amber-600 font-semibold mt-0.5">Awaiting dispatch / in transit</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl flex items-center gap-md shadow-sm">
|
||||
<div className="p-2 bg-emerald-50 text-emerald-600 rounded-lg">
|
||||
<Truck size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] text-zinc-400 uppercase tracking-wider font-bold">Successful Deliveries</p>
|
||||
<p className="font-sans font-bold text-lg text-zinc-800">
|
||||
{completedDeliveriesCount} done
|
||||
</p>
|
||||
<p className="text-[10px] text-[#581c87] font-semibold mt-0.5">{locationid ? 'At this location' : 'Across all locations'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl flex items-center gap-md shadow-sm">
|
||||
<div className="p-2 bg-purple-50 text-purple-600 rounded-lg">
|
||||
<UserCheck size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] text-zinc-400 uppercase tracking-wider font-bold">Active Delivery Fleet</p>
|
||||
<p className="font-sans font-bold text-lg text-zinc-800">
|
||||
{executives.filter(e => e.status !== 'Offline').length} partners
|
||||
</p>
|
||||
<p className="text-[10px] text-purple-600 font-semibold mt-0.5">{executives.length} riders registered</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Day-wise date filter — drives the live deliveries + summary queries */}
|
||||
<div className="bg-white border border-[#e2e8f0] rounded-xl p-md shadow-sm flex flex-col lg:flex-row lg:items-center justify-between gap-md">
|
||||
<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 pr-1">
|
||||
<Calendar size={13} className="text-[#581c87]" /> View
|
||||
</span>
|
||||
{presets.map((p) => (
|
||||
<button
|
||||
key={p.key}
|
||||
onClick={() => { setFromdate(p.from); setTodate(p.to); }}
|
||||
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold transition-all border cursor-pointer ${
|
||||
activePreset === p.key
|
||||
? 'bg-[#581c87] text-white border-[#581c87] shadow-sm'
|
||||
: 'bg-white text-zinc-600 border-[#e2e8f0] hover:bg-zinc-50'
|
||||
}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-sm text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label className="text-[10px] font-bold text-zinc-400 uppercase tracking-wider">From</label>
|
||||
<input
|
||||
type="date"
|
||||
value={fromdate}
|
||||
max={todate}
|
||||
onChange={(e) => setFromdate(e.target.value)}
|
||||
className="border border-[#e2e8f0] rounded-lg px-2 py-1.5 text-zinc-700 font-medium outline-none focus:ring-1 focus:ring-[#581c87] cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-zinc-300">→</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label className="text-[10px] font-bold text-zinc-400 uppercase tracking-wider">To</label>
|
||||
<input
|
||||
type="date"
|
||||
value={todate}
|
||||
min={fromdate}
|
||||
max={ymd(today)}
|
||||
onChange={(e) => setTodate(e.target.value)}
|
||||
className="border border-[#e2e8f0] rounded-lg px-2 py-1.5 text-zinc-700 font-medium outline-none focus:ring-1 focus:ring-[#581c87] cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main interactive segment splits */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-gutter text-xs font-sans">
|
||||
|
||||
{/* Left List of Customer App Orders */}
|
||||
<div className="lg:col-span-2 space-y-md">
|
||||
<div className="bg-white border border-[#e2e8f0] rounded-xl shadow-sm overflow-hidden flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="p-md border-b border-[#e2e8f0] bg-[#f8fafc] flex flex-col gap-md">
|
||||
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-sm">
|
||||
<div>
|
||||
<h4 className="font-sans font-bold text-sm text-[#0f172a]">
|
||||
Customer Orders Feed ({filteredOrdersList.length})
|
||||
</h4>
|
||||
<p className="text-[10px] text-zinc-400 font-medium mt-0.5">Interactive list of customer purchases made via client app</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCreateMockOrder}
|
||||
className="bg-[#581c87] text-white px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider flex items-center justify-center gap-1 cursor-pointer hover:bg-purple-800 transition shadow-sm"
|
||||
>
|
||||
<Plus size={12} />
|
||||
Create Simulated Order
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center gap-sm w-full">
|
||||
{/* Local Search Input */}
|
||||
<div className="relative w-full sm:max-w-xs">
|
||||
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search orders by customer, street, ID..."
|
||||
value={localSearch}
|
||||
onChange={(e) => setLocalSearch(e.target.value)}
|
||||
className="w-full pl-8 pr-4 py-1.5 border border-[#e2e8f0] rounded-lg text-[11px] outline-none bg-white focus:ring-1 focus:ring-[#581c87] transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filter Status buttons */}
|
||||
<div className="flex gap-1 overflow-x-auto w-full sm:w-auto">
|
||||
{['ALL', 'PROCESSING', 'CONFIRMED', 'OUT_FOR_DELIVERY', 'DELIVERED'].map((st) => (
|
||||
<button
|
||||
key={st}
|
||||
onClick={() => setFilterStatus(st)}
|
||||
className={`px-2 py-1.5 rounded text-[9px] font-bold uppercase transition-all border outline-none cursor-pointer whitespace-nowrap ${
|
||||
filterStatus === st
|
||||
? 'bg-[#581c87] text-white border-[#581c87] shadow-sm'
|
||||
: 'bg-white text-zinc-500 border-[#e2e8f0] hover:bg-zinc-50'
|
||||
}`}
|
||||
>
|
||||
{st.replace(/_/g, ' ')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order item rows */}
|
||||
<div className="divide-y divide-[#f1f5f9] max-h-[480px] overflow-y-auto">
|
||||
{filteredOrdersList.length === 0 ? (
|
||||
<div className="p-xl text-center text-zinc-400 font-medium">
|
||||
No orders matching status filter found. Try another query or place a mock delivery item.
|
||||
</div>
|
||||
) : (
|
||||
filteredOrdersList.map(order => (
|
||||
<div
|
||||
key={order.id}
|
||||
onClick={() => setSelectedOrder(order)}
|
||||
className={`p-md flex items-center justify-between hover:bg-zinc-50 border-l-4 transition-all cursor-pointer ${
|
||||
selectedOrder?.id === order.id ? 'bg-[#faf5ff]/50 border-[#581c87]' : 'border-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-sm">
|
||||
<span className="font-bold text-zinc-700">{order.customerName}</span>
|
||||
<span className="text-[10px] text-zinc-400">• {order.time}</span>
|
||||
</div>
|
||||
<p className="text-zinc-500 truncate max-w-[24rem]">{order.address}</p>
|
||||
<div className="flex gap-sm py-1 items-center">
|
||||
<span className="bg-[#f1f5f9] px-1.5 py-0.5 rounded text-[9px] font-bold text-zinc-500 uppercase">{order.hub}</span>
|
||||
<span className="text-[9px] text-[#581c87] font-bold">{order.itemCount ?? order.items.length} Items</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right space-y-1">
|
||||
<p className="font-bold font-mono text-sm text-[#0f172a]">₹{order.amount.toLocaleString()}</p>
|
||||
<span className={`px-2 py-0.5 rounded text-[9px] font-bold tracking-wider inline-block uppercase ${
|
||||
order.status === 'DELIVERED'
|
||||
? 'bg-emerald-50 text-emerald-600 border border-emerald-100'
|
||||
: order.status === 'OUT_FOR_DELIVERY'
|
||||
? 'bg-purple-50 text-purple-700 border border-purple-100'
|
||||
: order.status === 'CONFIRMED'
|
||||
? 'bg-amber-50 text-amber-600 border border-amber-100 animate-pulse'
|
||||
: 'bg-zinc-100 text-zinc-650 border border-zinc-200'
|
||||
}`}>
|
||||
{order.status.replace(/_/g, ' ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delivery Executives Fleet Section */}
|
||||
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl shadow-sm">
|
||||
<span className="text-[10px] font-sans font-bold text-zinc-400 uppercase tracking-widest block pb-xs mb-md border-b border-[#f1f5f9]">
|
||||
Coimbatore Delivery Executive Fleet status
|
||||
</span>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-sm">
|
||||
{executives.map((ex) => (
|
||||
<div key={ex.id} className="p-sm border border-[#e2e8f0]/80 rounded-xl bg-[#f8fafc]/40 flex justify-between items-center">
|
||||
<div className="flex items-center gap-sm">
|
||||
<img
|
||||
src={ex.avatar}
|
||||
alt={ex.name}
|
||||
referrerPolicy="no-referrer"
|
||||
className="w-10 h-10 rounded-full object-cover border border-zinc-200 shrink-0"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-semibold text-zinc-800">{ex.name}</p>
|
||||
<p className="text-[10px] text-zinc-400 font-medium">Zone: <strong>{ex.currentZone}</strong> • Rated ★{ex.rating}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<span className={`px-1.5 py-0.5 rounded text-[9px] font-bold uppercase inline-block ${
|
||||
ex.status === 'Active Duty' ? 'bg-sky-50 text-sky-600 border border-sky-100' : ex.status === 'Idle' ? 'bg-emerald-50 text-emerald-600 border border-emerald-100' : 'bg-zinc-100 text-zinc-400'
|
||||
}`}>
|
||||
{ex.status}
|
||||
</span>
|
||||
<p className="text-[10px] text-zinc-500 font-semibold mt-1">Completed: {ex.completedToday}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{selectedOrder ? (
|
||||
<div className="bg-white border border-[#e2e8f0] rounded-xl p-md shadow-sm space-y-md animate-in zoom-in-95 duration-150">
|
||||
<span className="text-[10px] font-sans font-bold text-zinc-400 uppercase tracking-widest block pb-xs border-b border-[#f1f5f9]">
|
||||
Order Details: {selectedOrder.id}
|
||||
</span>
|
||||
|
||||
{/* Customer summary */}
|
||||
<div className="p-sm bg-[#f8fafc] rounded-lg border border-[#e2e8f0]/50 space-y-xs">
|
||||
<div className="flex justify-between font-semibold">
|
||||
<span>Customer Name</span>
|
||||
<span className="text-zinc-700">{selectedOrder.customerName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between font-semibold">
|
||||
<span>Contact info</span>
|
||||
<span className="text-zinc-600 font-mono">{selectedOrder.phone}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] text-zinc-400 font-bold uppercase block mt-1">Delivery Address</span>
|
||||
<p className="text-zinc-700 mt-0.5 leading-relaxed font-medium">{selectedOrder.address}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category items description list */}
|
||||
<div>
|
||||
<span className="text-[10px] text-zinc-400 font-bold uppercase tracking-wide block mb-sm">Ordered Grocery basket Items:</span>
|
||||
<div className="divide-y divide-[#f1f5f9] bg-zinc-50/50 p-2.5 rounded-lg border border-[#e2e8f0]/40">
|
||||
{selectedOrder.items.length === 0 && (
|
||||
<div className="py-2 flex justify-between items-center text-xs text-zinc-500">
|
||||
<span className="font-medium">{selectedOrder.itemCount ?? 0} line item(s)</span>
|
||||
<span className="text-[10px] text-zinc-400">Detail lines not loaded on board view</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedOrder.items.map((item, idx) => (
|
||||
<div key={idx} className="py-2 flex justify-between items-center text-xs">
|
||||
<div>
|
||||
<p className="font-bold text-[#0f172a]">{item.name}</p>
|
||||
<p className="text-[10px] text-zinc-400">Qty: {item.quantity} x ₹{item.price}</p>
|
||||
</div>
|
||||
<span className="font-bold font-mono text-zinc-700">₹{(item.price * Number(item.quantity))}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="pt-2 flex justify-between items-center font-bold text-sm text-[#581c87] border-t border-dashed border-[#e2e8f0]">
|
||||
<span>Grand Total Invoice</span>
|
||||
<span className="font-mono">₹{selectedOrder.amount.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interactive Status advancement controls */}
|
||||
<div className="pt-xs space-y-sm">
|
||||
<span className="text-[9px] font-sans font-bold text-zinc-400 uppercase tracking-widest block pb-xs border-b border-[#f1f5f9]">OPERATIONAL CONTROL</span>
|
||||
{selectedOrder.status === 'PROCESSING' && (
|
||||
<button
|
||||
onClick={() => handleUpdateStatus('CONFIRMED')}
|
||||
className="w-full bg-amber-500 hover:bg-amber-600 text-white font-bold text-[11px] py-2 rounded-xl transition duration-150 shadow-sm flex items-center justify-center gap-1 cursor-pointer border-none"
|
||||
>
|
||||
<Check size={14} /> Pack & Bag Order
|
||||
</button>
|
||||
)}
|
||||
{selectedOrder.status === 'CONFIRMED' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (selectedOrder.assignedRider === 'Pending Assignment') {
|
||||
alert('Please assign a delivery partner from the fleet roster first.');
|
||||
return;
|
||||
}
|
||||
handleUpdateStatus('OUT_FOR_DELIVERY');
|
||||
}}
|
||||
className="w-full bg-purple-600 hover:bg-purple-700 text-white font-bold text-[11px] py-2 rounded-xl transition duration-150 shadow-sm flex items-center justify-center gap-1 cursor-pointer border-none"
|
||||
>
|
||||
<Truck size={14} /> Dispatch Rider
|
||||
</button>
|
||||
)}
|
||||
{selectedOrder.status === 'OUT_FOR_DELIVERY' && (
|
||||
<button
|
||||
onClick={() => handleUpdateStatus('DELIVERED')}
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white font-bold text-[11px] py-2 rounded-xl transition duration-150 shadow-sm flex items-center justify-center gap-1 cursor-pointer border-none"
|
||||
>
|
||||
<CheckCircle2 size={14} /> Verify Delivery Handover
|
||||
</button>
|
||||
)}
|
||||
{selectedOrder.status === 'DELIVERED' && (
|
||||
<div className="bg-emerald-50 border border-emerald-250 text-emerald-800 font-bold text-[10px] py-2.5 rounded-xl text-center flex items-center justify-center gap-1 select-none">
|
||||
<CheckCircle2 size={13} className="text-emerald-600" /> Order Completed Successfully
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active Rider Assignment (only if not delivered) */}
|
||||
{selectedOrder.status !== 'DELIVERED' && (
|
||||
<div className="space-y-sm pt-xs">
|
||||
<div className="flex justify-between items-center border-b border-[#f1f5f9] pb-xs">
|
||||
<span className="text-[9px] font-sans font-bold text-zinc-400 uppercase tracking-widest">ASSIGN DELIVERY EXECUTIVE</span>
|
||||
<span className="text-[9px] text-[#581c87] font-bold">Fleet Roster</span>
|
||||
</div>
|
||||
<div className="space-y-1.5 max-h-[140px] overflow-y-auto pr-1">
|
||||
{executives.length === 0 ? (
|
||||
<p className="text-[10px] text-zinc-405">No riders currently available.</p>
|
||||
) : (
|
||||
executives.map(ex => {
|
||||
const isAssigned = selectedOrder.assignedRider === ex.name;
|
||||
return (
|
||||
<button
|
||||
key={ex.id}
|
||||
type="button"
|
||||
onClick={() => handleAssignRider(ex.name)}
|
||||
className={`w-full p-2 border rounded-xl flex items-center justify-between text-left transition-all cursor-pointer ${
|
||||
isAssigned
|
||||
? 'bg-purple-50 border-[#581c87] text-[#581c87] font-semibold'
|
||||
: 'bg-[#f8fafc]/50 hover:bg-zinc-55 border-[#e2e8f0] text-zinc-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<img src={ex.avatar} alt={ex.name} referrerPolicy="no-referrer" className="w-6 h-6 rounded-full object-cover border border-zinc-200" />
|
||||
<div>
|
||||
<p className="text-[10px] font-bold leading-tight">{ex.name}</p>
|
||||
<p className="text-[9px] text-zinc-450 leading-none">{ex.currentZone} • ★{ex.rating}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`text-[8px] font-bold uppercase px-1.5 py-0.5 rounded ${
|
||||
isAssigned
|
||||
? 'bg-[#581c87] text-white'
|
||||
: 'bg-zinc-200 text-zinc-650'
|
||||
}`}>
|
||||
{isAssigned ? 'Assigned' : 'Assign'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Simulated GPS map tracking path */}
|
||||
{selectedOrder.status === 'OUT_FOR_DELIVERY' && (
|
||||
<div className="space-y-xs pt-xs">
|
||||
<span className="text-[9px] font-sans font-bold text-zinc-400 uppercase tracking-widest block">
|
||||
LIVE GPS ROUTE TRACKER
|
||||
</span>
|
||||
<div className="relative overflow-hidden rounded-xl border border-zinc-200 bg-zinc-950 p-4 h-40 text-white flex flex-col justify-between font-sans shadow-inner select-none">
|
||||
{/* Grid background lines */}
|
||||
<div className="absolute inset-0 opacity-10 bg-[linear-gradient(to_right,#808080_1px,transparent_1px),linear-gradient(to_bottom,#808080_1px,transparent_1px)] bg-[size:12px_18px]" />
|
||||
|
||||
<svg className="absolute inset-0 w-full h-full" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="route-grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#c084fc" />
|
||||
<stop offset="100%" stopColor="#818cf8" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{/* Route path line */}
|
||||
<path
|
||||
d="M 30 110 C 60 70, 110 110, 160 40"
|
||||
fill="none"
|
||||
stroke="#1e293b"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M 30 110 C 60 70, 110 110, 160 40"
|
||||
fill="none"
|
||||
stroke="url(#route-grad)"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="200"
|
||||
strokeDashoffset="200"
|
||||
style={{
|
||||
animation: 'dash 6s linear infinite'
|
||||
}}
|
||||
/>
|
||||
{/* Hub Marker */}
|
||||
<circle cx="30" cy="110" r="5" fill="#c084fc" className="animate-pulse" />
|
||||
<circle cx="30" cy="110" r="3" fill="#a855f7" />
|
||||
{/* Destination Marker */}
|
||||
<circle cx="160" cy="40" r="5" fill="#f43f5e" className="animate-ping" />
|
||||
<circle cx="160" cy="40" r="3" fill="#e11d48" />
|
||||
</svg>
|
||||
|
||||
<style dangerouslySetInnerHTML={{__html: `
|
||||
@keyframes dash {
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
`}} />
|
||||
|
||||
{/* Map overlays */}
|
||||
<div className="z-10 flex justify-between items-start">
|
||||
<div className="bg-zinc-900/90 backdrop-blur-md px-2 py-0.5 rounded border border-zinc-800 text-[8px] font-bold text-zinc-300">
|
||||
GPS ACTIVE: IN TRANSIT
|
||||
</div>
|
||||
<div className="bg-zinc-900/90 backdrop-blur-md px-2 py-0.5 rounded border border-zinc-800 text-[8px] font-bold text-[#c084fc] flex items-center gap-1">
|
||||
<span className="w-1 h-1 rounded-full bg-purple-500 animate-ping" />
|
||||
ETA 9 MINS
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="z-10 bg-zinc-900/95 backdrop-blur-md p-2 rounded-lg border border-zinc-800 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-[8px] text-zinc-400 font-bold uppercase tracking-wider">Executive</p>
|
||||
<p className="text-[10px] font-bold text-white leading-tight">{selectedOrder.assignedRider}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-[8px] text-zinc-400 font-bold uppercase tracking-wider">Distance</p>
|
||||
<p className="text-[10px] font-bold text-[#c084fc] font-mono leading-tight">1.2 km left</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delivery tracking visual roadmap layout */}
|
||||
<div className="bg-zinc-50 border border-[#e2e8f0]/60 rounded-xl p-md">
|
||||
<span className="text-[9px] font-sans font-bold text-zinc-400 uppercase tracking-widest block pb-xs mb-sm border-b border-[#f1f5f9]">
|
||||
Live Dispatch Timeline Tracker
|
||||
</span>
|
||||
|
||||
<div className="space-y-xs pt-1 relative text-[11px]">
|
||||
<div className="flex gap-md items-start relative group">
|
||||
<span className="text-emerald-500 mt-0.5"><CheckCircle2 size={12} /></span>
|
||||
<div>
|
||||
<h5 className="font-semibold text-zinc-800">Order Received ({selectedOrder.time})</h5>
|
||||
<p className="text-[10px] text-zinc-400">Placed via customer app cart checkout successfully.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-md items-start pt-3">
|
||||
<span className={['CONFIRMED', 'OUT_FOR_DELIVERY', 'DELIVERED'].includes(selectedOrder.status) ? 'text-emerald-500 mt-0.5' : 'text-zinc-300 mt-0.5'}><CheckCircle2 size={12} /></span>
|
||||
<div>
|
||||
<h5 className="font-semibold text-zinc-800">Assortment Packaged & Bagged</h5>
|
||||
<p className="text-[10px] text-zinc-400">Verified fresh produce items in-stock levels.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-md items-start pt-3">
|
||||
<span className={['OUT_FOR_DELIVERY', 'DELIVERED'].includes(selectedOrder.status) ? 'text-emerald-500 mt-0.5' : 'text-zinc-300 mt-0.5'}><CheckCircle2 size={12} /></span>
|
||||
<div>
|
||||
<h5 className="font-semibold text-zinc-800">Out for Delivery</h5>
|
||||
<p className="text-[10px] text-zinc-400">Dispatched with executive partner on bike route.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-md items-start pt-3">
|
||||
<span className={selectedOrder.status === 'DELIVERED' ? 'text-emerald-500 mt-0.5' : 'text-zinc-300 mt-0.5'}><CheckCircle2 size={12} /></span>
|
||||
<div>
|
||||
<h5 className="font-semibold text-zinc-800">Handover Verified</h5>
|
||||
<p className="text-[10px] text-zinc-400">Delivered directly to door step location.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-xl bg-white border border-[#e2e8f0] rounded-xl text-center text-zinc-400 font-medium">
|
||||
Select any customer order from the feed to view its details.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
478
src/components/ReportsView.tsx
Normal file
478
src/components/ReportsView.tsx
Normal file
@@ -0,0 +1,478 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Download,
|
||||
Filter,
|
||||
ArrowUpRight,
|
||||
ChevronDown,
|
||||
TrendingUp as TrendUp,
|
||||
TrendingDown as TrendDown,
|
||||
Equal as TrendFlat,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Info
|
||||
} from 'lucide-react';
|
||||
import { LeaderboardNode } from '../types';
|
||||
import {
|
||||
useFiestaOrderSummary,
|
||||
useFiestaLocationSummary,
|
||||
useFiestaOrderInsight,
|
||||
useFiestaStockStatement,
|
||||
} 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';
|
||||
|
||||
interface ReportsViewProps {
|
||||
searchQuery: string;
|
||||
isCoimbatoreView: boolean;
|
||||
}
|
||||
|
||||
const MONTH_KEYS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dece'];
|
||||
const MONTH_LABELS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
export default function ReportsView({ searchQuery, isCoimbatoreView }: ReportsViewProps) {
|
||||
const [selectedTimeframe, setSelectedTimeframe] = useState('Month to Date');
|
||||
const [stockFilter, setStockFilter] = useState<'All' | 'Healthy' | 'Low Stock' | 'Critical'>('All');
|
||||
const [selectedCell, setSelectedCell] = useState<{ day: string; hour: string; val: number } | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
// ── Live analytics (Fiesta) ───────────────────────────────────────────────
|
||||
const today = new Date();
|
||||
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
const yearStart = new Date(today.getFullYear(), 0, 1);
|
||||
const fromdate = ymd(monthStart);
|
||||
const todate = ymd(today);
|
||||
|
||||
const summaryQ = useFiestaOrderSummary(FIESTA_TENANT_ID, ymd(yearStart), todate);
|
||||
const locSummaryQ = useFiestaLocationSummary(FIESTA_TENANT_ID);
|
||||
const insightQ = useFiestaOrderInsight(FIESTA_TENANT_ID);
|
||||
const stockQ = useFiestaStockStatement({
|
||||
tenantid: FIESTA_TENANT_ID,
|
||||
locationid: FIESTA_PRIMARY_LOCATION_ID,
|
||||
keyword: '',
|
||||
pageno: 1,
|
||||
pagesize: 100,
|
||||
});
|
||||
|
||||
const s = summaryQ.data;
|
||||
const activeSkus = (stockQ.data ?? []).length;
|
||||
|
||||
// KPI row — all live.
|
||||
const reportsKPIs = [
|
||||
{
|
||||
title: 'Total Orders (YTD)',
|
||||
value: (s?.total ?? 0).toLocaleString('en-IN'),
|
||||
subtext: `${ymd(yearStart)} → ${todate}`,
|
||||
trend: `${s?.delivered ?? 0} delivered`,
|
||||
isPositive: true,
|
||||
},
|
||||
{
|
||||
title: 'Delivered',
|
||||
value: (s?.delivered ?? 0).toLocaleString('en-IN'),
|
||||
subtext: `${s ? Math.round(((s.delivered) / Math.max(s.total, 1)) * 100) : 0}% of all orders`,
|
||||
trend: `${s?.pending ?? 0} pending`,
|
||||
isPositive: true,
|
||||
},
|
||||
{
|
||||
title: 'Cancelled',
|
||||
value: (s?.cancelled ?? 0).toLocaleString('en-IN'),
|
||||
subtext: `${s ? Math.round(((s.cancelled) / Math.max(s.total, 1)) * 100) : 0}% of all orders`,
|
||||
trend: `${s?.created ?? 0} created`,
|
||||
isPositive: false,
|
||||
},
|
||||
{
|
||||
title: 'Active SKUs',
|
||||
value: activeSkus.toLocaleString('en-IN'),
|
||||
subtext: 'Live stock statement entries',
|
||||
trend: 'In catalogue',
|
||||
isPositive: true,
|
||||
},
|
||||
];
|
||||
|
||||
// Leaderboard — outlets ranked by total live orders.
|
||||
const leaderboard: LeaderboardNode[] = (() => {
|
||||
const rows = [...(locSummaryQ.data ?? [])].sort((a, b) => b.total - a.total).slice(0, 4);
|
||||
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}`,
|
||||
percentage: max > 0 ? Math.round((r.total / max) * 100) : 0,
|
||||
revenue: `${r.total.toLocaleString('en-IN')} ord`,
|
||||
}));
|
||||
})();
|
||||
const currentLeaderboard = leaderboard;
|
||||
|
||||
// Monthly order distribution per outlet (replaces the static hourly heatmap).
|
||||
const insightRows = (insightQ.data ?? []).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]))),
|
||||
);
|
||||
|
||||
// Live product performance matrix.
|
||||
const liveProducts = (stockQ.data ?? []).map(stockRowToProduct);
|
||||
const filteredProducts = liveProducts.filter((prod) => {
|
||||
const matchesSearch =
|
||||
prod.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
prod.sku.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesStock = stockFilter === 'All' ? true : prod.stockStatus === stockFilter;
|
||||
return matchesSearch && matchesStock;
|
||||
});
|
||||
|
||||
// Heatmap cell colour, scaled relative to the busiest month/outlet.
|
||||
const getHeatmapColorClass = (val: number) => {
|
||||
const ratio = val / heatmapMax;
|
||||
if (ratio < 0.15) return 'bg-[#581c87]/10 text-[#0f172a] hover:bg-[#581c87]/20';
|
||||
if (ratio <= 0.5) return 'bg-[#a78bfa]/40 text-[#581c87] hover:bg-[#a78bfa]/50';
|
||||
return 'bg-[#581c87] text-white hover:bg-purple-800';
|
||||
};
|
||||
|
||||
// Export alerts
|
||||
const triggerExport = (format: 'PDF' | 'CSV') => {
|
||||
alert(`BI Engine initiating automated ${format} bundle export. Generating compiled schema reports...`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-lg animate-in fade-in duration-500">
|
||||
|
||||
{/* Context filter header row */}
|
||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-md bg-[#f8fafc] border border-[#e2e8f0] p-md rounded-xl shadow-sm">
|
||||
<div>
|
||||
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a]">
|
||||
Business Intelligence Center
|
||||
</h1>
|
||||
<p className="text-zinc-500 font-sans text-xs mt-1">
|
||||
Real-time analytical metrics engine surfacing regional performance deltas and potential logistic constraints.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action picker filters */}
|
||||
<div className="flex items-center gap-sm flex-wrap text-xs">
|
||||
<div className="bg-white border border-[#e2e8f0] rounded-lg px-sm py-1.5 flex items-center gap-sm shadow-sm select-none">
|
||||
<span className="text-zinc-400 font-medium">Timeframe:</span>
|
||||
<select
|
||||
value={selectedTimeframe}
|
||||
onChange={(e) => setSelectedTimeframe(e.target.value)}
|
||||
className="bg-transparent border-none focus:ring-0 font-sans font-semibold text-zinc-700 cursor-pointer outline-none"
|
||||
>
|
||||
<option>Month to Date</option>
|
||||
<option>Year to Date</option>
|
||||
<option>Last 12 Months</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-[#e2e8f0] rounded-lg px-sm py-1.5 flex items-center gap-sm shadow-sm font-semibold text-zinc-700">
|
||||
<Filter size={14} className="text-zinc-400 font-medium" />
|
||||
<span>{isCoimbatoreView ? 'Coimbatore Zones (5)' : 'All Regions (12)'}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => triggerExport('PDF')}
|
||||
className="bg-[#581c87] text-white font-sans font-semibold px-4 py-1.5 rounded-lg flex items-center gap-sm cursor-pointer transition-colors hover:bg-purple-800 active:bg-purple-900 shadow-sm"
|
||||
>
|
||||
<Download size={13} />
|
||||
Export PDF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Primary KPI Row - 4 Key cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-gutter mb-xl text-xs">
|
||||
{reportsKPIs.map((kpi, idx) => (
|
||||
<div key={idx} className="bg-white border border-[#e2e8f0] rounded-xl p-md shadow-sm">
|
||||
<div className="flex justify-between items-start mb-sm">
|
||||
<span className="text-[11px] font-sans font-bold text-zinc-400 uppercase tracking-widest block font-sans">
|
||||
{kpi.title}
|
||||
</span>
|
||||
|
||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded flex items-center gap-px ${
|
||||
kpi.isPositive ? 'bg-emerald-50 text-emerald-600' : 'bg-rose-50 text-rose-600'
|
||||
}`}>
|
||||
{kpi.isPositive ? <TrendingUp size={10} /> : <TrendingDown size={10} />}
|
||||
{kpi.trend}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="font-sans font-bold text-[#0f172a] text-xl tracking-tight">
|
||||
{kpi.value}
|
||||
</div>
|
||||
|
||||
<div className="text-[10px] text-zinc-400 font-medium tracking-wide mt-sm uppercase font-sans">
|
||||
{kpi.subtext}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bento split maps */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-gutter">
|
||||
|
||||
{/* Revenue Heatmap table - 8 Cols */}
|
||||
<div className="lg:col-span-8 bg-white border border-[#e2e8f0] rounded-xl overflow-hidden flex flex-col justify-between shadow-sm">
|
||||
|
||||
<div className="bg-[#f8fafc] border-b border-[#e2e8f0] px-md py-sm flex justify-between items-center">
|
||||
<span className="text-[11px] font-sans font-bold text-[#0f172a] uppercase tracking-widest block">
|
||||
Monthly Order Distribution by Outlet
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2 text-[10px] font-bold text-zinc-400 uppercase tracking-tight">
|
||||
<span className="w-2 h-2 rounded-full bg-[#581c87] animate-pulse"></span>
|
||||
<span>Busiest Month</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-md flex-1 overflow-x-auto">
|
||||
{selectedCell && (
|
||||
<div className="mb-sm p-sm bg-purple-50 border border-purple-100 rounded-lg text-xs flex justify-between items-center animate-in fade-in">
|
||||
<span className="font-sans text-blue-900 font-medium">
|
||||
<strong className="font-bold">{selectedCell.day}</strong> registered <strong className="font-bold font-mono">{selectedCell.val}</strong> order(s) in <strong className="font-bold">{selectedCell.hour}</strong>.
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setSelectedCell(null)}
|
||||
className="text-xs font-bold text-[#581c87] hover:underline cursor-pointer"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{insightRows.length === 0 ? (
|
||||
<div className="py-xl text-center text-zinc-400 text-xs">
|
||||
{insightQ.isLoading ? 'Loading monthly order distribution…' : 'No order insight available for this tenant.'}
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-center border-collapse text-xs font-sans">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="p-xs text-[10px] font-bold text-zinc-400 w-32 uppercase italic">Outlet</th>
|
||||
{MONTH_LABELS.map((m) => (
|
||||
<th key={m} className="p-sm text-[10px] font-bold text-[#0f172a] border-b border-[#f1f5f9]">
|
||||
{m}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<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]">
|
||||
{row.name}
|
||||
</td>
|
||||
{MONTH_KEYS.map((key, mIdx) => {
|
||||
const val = fnum(row.months[key]);
|
||||
return (
|
||||
<td key={key} className="p-1 border border-white">
|
||||
<button
|
||||
onClick={() => setSelectedCell({ day: row.name, hour: MONTH_LABELS[mIdx], val })}
|
||||
className={`w-full py-2.5 rounded-lg font-semibold transition-all duration-100 cursor-pointer ${getHeatmapColorClass(val)}`}
|
||||
>
|
||||
{val}
|
||||
</button>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-md py-sm bg-[#f8fafc] border-t border-[#e2e8f0] flex justify-between text-[11px] text-zinc-400 font-sans font-medium">
|
||||
<span>Colour intensity scales with monthly order volume per outlet</span>
|
||||
<span>Click cells to inspect metrics</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Leaderboard nodes bar list - 4 Cols */}
|
||||
<div className="lg:col-span-4 bg-white border border-[#e2e8f0] rounded-xl flex flex-col shadow-sm mt-0">
|
||||
<div className="bg-[#f8fafc] border-b border-[#e2e8f0] px-md py-sm">
|
||||
<span className="text-[11px] font-sans font-bold text-[#0f172a] uppercase tracking-widest block">
|
||||
Top Performing Nodes
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-md flex-1 space-y-md flex flex-col justify-center">
|
||||
{currentLeaderboard.map((node) => (
|
||||
<div key={node.rank} className="flex items-center gap-md text-xs">
|
||||
<span className="text-xs font-bold text-zinc-400 font-mono w-4">
|
||||
{node.rank}
|
||||
</span>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between items-center font-medium">
|
||||
<span className="text-[#0f172a] font-semibold">{node.name}</span>
|
||||
<span className="text-[#581c87] font-mono font-bold">{node.revenue}</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-[#eceef0] h-1.5 rounded-full mt-1 overflow-hidden">
|
||||
<div
|
||||
className="bg-[#581c87] h-full rounded-full transition-all duration-300"
|
||||
style={{ width: `${node.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detailed Performance Matrix table */}
|
||||
<div className="bg-white border border-[#e2e8f0] rounded-xl overflow-hidden shadow-sm">
|
||||
|
||||
{/* Table header with filters control */}
|
||||
<div className="bg-[#f8fafc] border-b border-[#e2e8f0] p-md flex flex-col sm:flex-row justify-between items-start sm:items-center gap-sm">
|
||||
<div>
|
||||
<h3 className="font-sans font-bold text-sm text-[#0f172a]">
|
||||
Product Performance Matrix
|
||||
</h3>
|
||||
<p className="text-zinc-500 text-xs font-sans mt-0.5">
|
||||
Assortment unit sales and physical balance trend indices.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick interactive filter pills */}
|
||||
<div className="flex gap-2 text-xs font-semibold">
|
||||
{(['All', 'Healthy', 'Low Stock', 'Critical'] as const).map((filter) => (
|
||||
<button
|
||||
key={filter}
|
||||
onClick={() => setStockFilter(filter)}
|
||||
className={`px-3 py-1 rounded-lg cursor-pointer transition-colors ${
|
||||
stockFilter === filter
|
||||
? 'bg-[#581c87] text-white shadow-sm'
|
||||
: 'bg-white border border-[#e2e8f0] text-zinc-600 hover:bg-zinc-50'
|
||||
}`}
|
||||
>
|
||||
{filter}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={() => triggerExport('CSV')}
|
||||
className="px-3 py-1 bg-[#0f172a] text-white rounded-lg cursor-pointer hover:bg-zinc-800 transition-colors shadow-sm"
|
||||
>
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Matrix Data table */}
|
||||
<div className="overflow-x-auto text-xs font-sans">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-[#f8fafc] border-b border-[#e2e8f0] text-zinc-500 text-[10px] uppercase font-bold tracking-wider">
|
||||
<tr>
|
||||
<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">Stock Status</th>
|
||||
<th className="px-md py-sm text-center">Trend index</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#f1f5f9]">
|
||||
{filteredProducts.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center py-8 text-zinc-400">
|
||||
No matching items matching stock filter criteria.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredProducts.map((prod) => (
|
||||
<tr key={prod.id} className="hover:bg-[#f2f4f6]/40 transition-all">
|
||||
<td className="px-md py-md flex items-center gap-sm">
|
||||
<div className="w-8 h-8 rounded-lg shrink-0 border border-[#e2e8f0] overflow-hidden bg-zinc-50">
|
||||
<img
|
||||
src={prod.image}
|
||||
alt={prod.name}
|
||||
referrerPolicy="no-referrer"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span className="font-semibold text-[#0f172a]">{prod.name}</span>
|
||||
</td>
|
||||
<td className="px-md py-md font-mono text-zinc-500 font-medium">
|
||||
{prod.sku}
|
||||
</td>
|
||||
<td className="px-md py-md text-right font-mono text-zinc-600 font-semibold">
|
||||
{prod.unitsSold.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-md py-md text-right font-mono text-zinc-700 font-bold">
|
||||
₹{prod.revenue.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-md py-md text-right">
|
||||
<span className={`px-2 py-0.5 rounded text-[9px] font-bold uppercase tracking-tight ${
|
||||
prod.stockStatus === 'Healthy'
|
||||
? 'bg-emerald-100 text-emerald-750 font-bold font-sans'
|
||||
: prod.stockStatus === 'Low Stock'
|
||||
? 'bg-amber-100 text-amber-750 font-bold font-sans'
|
||||
: 'bg-rose-100 text-rose-750 font-bold font-sans'
|
||||
}`}>
|
||||
{prod.stockStatus}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-md py-md text-center">
|
||||
<span className="inline-block p-1 bg-[#f8fafc] border border-zinc-100 rounded-full">
|
||||
{prod.trend === 'up' ? (
|
||||
<TrendUp size={14} className="text-emerald-500" />
|
||||
) : prod.trend === 'down' ? (
|
||||
<TrendDown size={14} className="text-rose-500" />
|
||||
) : (
|
||||
<TrendFlat size={14} className="text-zinc-400" />
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Matrix table pagination */}
|
||||
<div className="p-md bg-[#f8fafc] border-t border-[#e2e8f0] flex justify-between items-center text-[10px] text-zinc-500 font-semibold font-sans">
|
||||
<span>Showing 1-{filteredProducts.length} of {liveProducts.length} live products</span>
|
||||
|
||||
<div className="flex gap-xs">
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||
className="w-8 h-8 border border-[#e2e8f0] rounded-lg flex items-center justify-center hover:bg-white active:bg-[#f8fafc] cursor-pointer"
|
||||
>
|
||||
<ChevronLeft size={12} />
|
||||
</button>
|
||||
<button className={`w-8 h-8 border border-[#e2e8f0] rounded-lg flex items-center justify-center font-bold text-[10px] bg-[#0f172a] text-white`}>
|
||||
1
|
||||
</button>
|
||||
<button
|
||||
onClick={() => alert('Proceeding to page 2 details representation')}
|
||||
className="w-8 h-8 border border-[#e2e8f0] rounded-lg flex items-center justify-center hover:bg-white text-zinc-500 font-bold font-mono text-[10px] cursor-pointer"
|
||||
>
|
||||
2
|
||||
</button>
|
||||
<button
|
||||
onClick={() => alert('Proceeding to page 3 details representation')}
|
||||
className="w-8 h-8 border border-[#e2e8f0] rounded-lg flex items-center justify-center hover:bg-white text-zinc-500 font-bold font-mono text-[10px] cursor-pointer"
|
||||
>
|
||||
3
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => prev + 1)}
|
||||
className="w-8 h-8 border border-[#e2e8f0] rounded-lg flex items-center justify-center hover:bg-white active:bg-[#f8fafc] cursor-pointer"
|
||||
>
|
||||
<ChevronRight size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
511
src/components/SettingsView.tsx
Normal file
511
src/components/SettingsView.tsx
Normal file
@@ -0,0 +1,511 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Building2,
|
||||
Store,
|
||||
Truck,
|
||||
CreditCard,
|
||||
SlidersHorizontal,
|
||||
MapPin,
|
||||
Phone,
|
||||
Mail,
|
||||
Check,
|
||||
RotateCcw,
|
||||
CheckCircle2,
|
||||
} from 'lucide-react';
|
||||
import { useFiestaAllTenants, useFiestaTenantLocations } from '../services/fiestaQueries';
|
||||
import { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../services/fiestaApi';
|
||||
|
||||
interface SettingsViewProps {
|
||||
tenantId?: number;
|
||||
}
|
||||
|
||||
type TabKey = 'profile' | 'outlets' | 'delivery' | 'payment' | 'preferences';
|
||||
|
||||
/** Locally-persisted merchant preferences (survive reload via localStorage). */
|
||||
interface MerchantSettings {
|
||||
// Business profile (seeded from live tenant data, then locally editable)
|
||||
contactEmail: string;
|
||||
contactPhone: string;
|
||||
minOrderValue: number;
|
||||
// Delivery
|
||||
deliveryCharge: number;
|
||||
prepMins: number;
|
||||
deliveryWindowMins: number;
|
||||
cancelWindowSecs: number;
|
||||
autoAssignRider: boolean;
|
||||
// Payment & tax
|
||||
defaultTaxPercent: number;
|
||||
codEnabled: boolean;
|
||||
onlinePaymentEnabled: boolean;
|
||||
// Preferences
|
||||
defaultRegion: string;
|
||||
defaultNewUserRole: number;
|
||||
orderNotifications: boolean;
|
||||
lowStockAlerts: boolean;
|
||||
dailySummaryEmail: boolean;
|
||||
syncInterval: number;
|
||||
sandboxMode: boolean;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'merchant-settings-v1';
|
||||
|
||||
const DEFAULTS: MerchantSettings = {
|
||||
contactEmail: '',
|
||||
contactPhone: '',
|
||||
minOrderValue: 0,
|
||||
deliveryCharge: 30,
|
||||
prepMins: 15,
|
||||
deliveryWindowMins: 45,
|
||||
cancelWindowSecs: 60,
|
||||
autoAssignRider: true,
|
||||
defaultTaxPercent: 5,
|
||||
codEnabled: true,
|
||||
onlinePaymentEnabled: true,
|
||||
defaultRegion: 'Coimbatore',
|
||||
defaultNewUserRole: 4,
|
||||
orderNotifications: true,
|
||||
lowStockAlerts: true,
|
||||
dailySummaryEmail: false,
|
||||
syncInterval: 5,
|
||||
sandboxMode: false,
|
||||
};
|
||||
|
||||
function loadSettings(): { settings: MerchantSettings; hadSaved: boolean } {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) return { settings: { ...DEFAULTS, ...JSON.parse(raw) }, hadSaved: true };
|
||||
} catch {
|
||||
/* ignore corrupt storage */
|
||||
}
|
||||
return { settings: { ...DEFAULTS }, hadSaved: false };
|
||||
}
|
||||
|
||||
// ── Small presentational helpers ────────────────────────────────────────────
|
||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: () => void }) {
|
||||
return (
|
||||
<label className="relative inline-flex items-center cursor-pointer shrink-0">
|
||||
<input type="checkbox" checked={checked} onChange={onChange} className="sr-only peer" />
|
||||
<div className="w-9 h-5 bg-zinc-200 rounded-full peer peer-focus:ring-0 peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-zinc-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-[#581c87]" />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
title,
|
||||
desc,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
desc?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex justify-between items-center gap-md p-sm bg-zinc-50 rounded-lg border border-[#e2e8f0]/45">
|
||||
<div className="min-w-0">
|
||||
<h4 className="font-sans font-bold text-xs text-[#0f172a]">{title}</h4>
|
||||
{desc && <p className="text-zinc-400 text-[10px] mt-xs">{desc}</p>}
|
||||
</div>
|
||||
<div className="shrink-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const numberInputCls =
|
||||
'w-24 border border-[#e2e8f0] rounded-lg p-1.5 text-right font-semibold text-zinc-700 bg-white outline-none focus:ring-1 focus:ring-[#581c87]';
|
||||
const textInputCls =
|
||||
'w-full border border-[#e2e8f0] rounded-lg p-sm bg-white outline-none focus:ring-1 focus:ring-[#581c87] text-zinc-700 font-medium';
|
||||
const selectCls =
|
||||
'border border-[#e2e8f0] bg-white rounded-lg p-1.5 font-semibold text-zinc-700 outline-none cursor-pointer';
|
||||
|
||||
export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsViewProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('profile');
|
||||
|
||||
// Live tenant profile + outlets.
|
||||
const tenantsQ = useFiestaAllTenants({ pagesize: 50 });
|
||||
const tenant = (tenantsQ.data ?? []).find((t) => Number(t.tenantid) === tenantId) || null;
|
||||
const locationsQ = useFiestaTenantLocations(tenantId);
|
||||
const outlets = locationsQ.data ?? [];
|
||||
|
||||
// Persisted preferences.
|
||||
const initial = useRef(loadSettings());
|
||||
const [form, setForm] = useState<MerchantSettings>(initial.current.settings);
|
||||
const [saved, setSaved] = useState<MerchantSettings>(initial.current.settings);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
|
||||
// First-run seeding: if nothing was saved yet, fill contact/min-order/region
|
||||
// from the live tenant once it arrives.
|
||||
const seededRef = useRef(initial.current.hadSaved);
|
||||
useEffect(() => {
|
||||
if (seededRef.current || !tenant) return;
|
||||
seededRef.current = true;
|
||||
const seed = (prev: MerchantSettings): MerchantSettings => ({
|
||||
...prev,
|
||||
contactEmail: prev.contactEmail || fstr(tenant.primaryemail),
|
||||
contactPhone: prev.contactPhone || fstr(tenant.primarycontact),
|
||||
minOrderValue: prev.minOrderValue || fnum(tenant.minorder),
|
||||
defaultRegion: prev.defaultRegion || fstr(tenant.city) || 'Coimbatore',
|
||||
});
|
||||
setForm(seed);
|
||||
setSaved(seed);
|
||||
}, [tenant]);
|
||||
|
||||
const dirty = useMemo(() => JSON.stringify(form) !== JSON.stringify(saved), [form, saved]);
|
||||
|
||||
const set = <K extends keyof MerchantSettings>(key: K, value: MerchantSettings[K]) =>
|
||||
setForm((f) => ({ ...f, [key]: value }));
|
||||
|
||||
const handleSave = () => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
|
||||
} catch {
|
||||
/* ignore quota errors */
|
||||
}
|
||||
setSaved(form);
|
||||
setToast('Settings saved');
|
||||
window.setTimeout(() => setToast(null), 2200);
|
||||
};
|
||||
|
||||
const handleReset = () => setForm(saved);
|
||||
|
||||
const tabs: Array<{ key: TabKey; label: string; icon: typeof Building2 }> = [
|
||||
{ key: 'profile', label: 'Business Profile', icon: Building2 },
|
||||
{ key: 'outlets', label: 'Outlets', icon: Store },
|
||||
{ key: 'delivery', label: 'Delivery', icon: Truck },
|
||||
{ key: 'payment', label: 'Payment & Tax', icon: CreditCard },
|
||||
{ key: 'preferences', label: 'Preferences', icon: SlidersHorizontal },
|
||||
];
|
||||
|
||||
const roleOptions = [1, 2, 3, 4, 6];
|
||||
|
||||
return (
|
||||
<div className="space-y-lg animate-in fade-in duration-300 relative">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a]">Settings</h1>
|
||||
<p className="text-zinc-500 font-sans text-xs mt-1">
|
||||
Manage your store profile, outlets, delivery, payments, and workspace preferences.
|
||||
</p>
|
||||
<div className="mt-1.5">
|
||||
{tenantsQ.isLoading ? (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-zinc-400 uppercase tracking-wide">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-zinc-300 animate-pulse" /> Loading live profile…
|
||||
</span>
|
||||
) : tenant ? (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-emerald-600 uppercase tracking-wide">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" /> Live · {fstr(tenant.tenantname)} · Tenant {tenantId}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-rose-600 uppercase tracking-wide">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-rose-500" /> Tenant profile unavailable
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-gutter items-start">
|
||||
{/* Tab rail */}
|
||||
<nav className="lg:col-span-1 bg-white border border-[#e2e8f0] rounded-xl p-2 shadow-sm flex lg:flex-col gap-1 overflow-x-auto">
|
||||
{tabs.map((t) => {
|
||||
const Icon = t.icon;
|
||||
const active = activeTab === t.key;
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setActiveTab(t.key)}
|
||||
className={`flex items-center gap-sm px-sm py-2 rounded-lg text-xs font-semibold transition-colors whitespace-nowrap cursor-pointer ${
|
||||
active ? 'bg-[#faf5ff] text-[#581c87]' : 'text-zinc-600 hover:bg-zinc-50'
|
||||
}`}
|
||||
>
|
||||
<Icon size={15} className={active ? 'text-[#581c87]' : 'text-zinc-400'} />
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Panel */}
|
||||
<div className="lg:col-span-3 space-y-gutter text-xs font-sans">
|
||||
{activeTab === 'profile' && (
|
||||
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl shadow-sm space-y-md">
|
||||
<span className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest block pb-xs border-b border-[#f1f5f9]">
|
||||
Business Profile
|
||||
</span>
|
||||
|
||||
{/* Live identity (read-only) */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-sm">
|
||||
<div className="p-sm bg-zinc-50 rounded-lg border border-[#e2e8f0]/45">
|
||||
<span className="text-[9px] text-zinc-400 uppercase font-bold tracking-widest">Store Name</span>
|
||||
<p className="font-bold text-[#0f172a] mt-0.5">{fstr(tenant?.tenantname) || '—'}</p>
|
||||
</div>
|
||||
<div className="p-sm bg-zinc-50 rounded-lg border border-[#e2e8f0]/45">
|
||||
<span className="text-[9px] text-zinc-400 uppercase font-bold tracking-widest">Legal / Company</span>
|
||||
<p className="font-bold text-[#0f172a] mt-0.5">{fstr(tenant?.companyname) || '—'}</p>
|
||||
</div>
|
||||
<div className="p-sm bg-zinc-50 rounded-lg border border-[#e2e8f0]/45">
|
||||
<span className="text-[9px] text-zinc-400 uppercase font-bold tracking-widest">Category</span>
|
||||
<p className="font-bold text-[#0f172a] mt-0.5">{fstr(tenant?.subcategoryname) || `Category ${fnum(tenant?.categoryid)}`}</p>
|
||||
</div>
|
||||
<div className="p-sm bg-zinc-50 rounded-lg border border-[#e2e8f0]/45 flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-[9px] text-zinc-400 uppercase font-bold tracking-widest">Account Status</span>
|
||||
<p className="font-bold text-[#0f172a] mt-0.5">{fstr(tenant?.status) || '—'}</p>
|
||||
</div>
|
||||
<span className={`px-1.5 py-0.5 rounded text-[9px] font-bold uppercase ${
|
||||
fstr(tenant?.status).toLowerCase() === 'active'
|
||||
? 'text-emerald-700 bg-emerald-100'
|
||||
: 'text-zinc-500 bg-zinc-200'
|
||||
}`}>
|
||||
{fnum(tenant?.approved) === 1 ? 'Approved' : 'Pending'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-sm bg-zinc-50 rounded-lg border border-[#e2e8f0]/45 flex items-start gap-sm">
|
||||
<MapPin size={13} className="text-zinc-400 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<span className="text-[9px] text-zinc-400 uppercase font-bold tracking-widest">Registered Address</span>
|
||||
<p className="text-zinc-700 font-medium mt-0.5 leading-relaxed">
|
||||
{fstr(tenant?.address) || '—'}
|
||||
{tenant?.city ? ` · ${fstr(tenant.city)}, ${fstr(tenant.state)} ${fstr(tenant.postcode)}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editable contact (persisted locally) */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-sm pt-xs">
|
||||
<div className="space-y-1">
|
||||
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px] flex items-center gap-1">
|
||||
<Mail size={11} /> Contact Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={form.contactEmail}
|
||||
onChange={(e) => set('contactEmail', e.target.value)}
|
||||
className={textInputCls}
|
||||
placeholder="store@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px] flex items-center gap-1">
|
||||
<Phone size={11} /> Contact Phone
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.contactPhone}
|
||||
onChange={(e) => set('contactPhone', e.target.value)}
|
||||
className={textInputCls}
|
||||
placeholder="9876543210"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-zinc-400">
|
||||
Identity fields above are read live from your tenant record. Contact details are saved to this workspace.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'outlets' && (
|
||||
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl shadow-sm space-y-md">
|
||||
<div className="flex justify-between items-center pb-xs border-b border-[#f1f5f9]">
|
||||
<span className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest">Outlet Locations</span>
|
||||
<span className="text-[10px] text-[#581c87] font-bold bg-purple-50 px-2 py-0.5 rounded border border-purple-100">
|
||||
{locationsQ.isLoading ? 'Loading…' : `${outlets.length} outlet${outlets.length === 1 ? '' : 's'}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{locationsQ.isLoading ? (
|
||||
<div className="text-center py-lg text-zinc-400">Loading live outlets…</div>
|
||||
) : outlets.length === 0 ? (
|
||||
<div className="text-center py-lg text-zinc-400">No outlets found for this tenant.</div>
|
||||
) : (
|
||||
<div className="space-y-sm max-h-[28rem] overflow-y-auto">
|
||||
{outlets.map((loc, i) => (
|
||||
<div key={fstr(loc.locationid) || i} className="p-sm border border-[#e2e8f0] rounded-lg bg-[#f8fafc]/40">
|
||||
<div className="flex justify-between items-start gap-md">
|
||||
<div className="min-w-0">
|
||||
<p className="font-bold text-[#0f172a] truncate">{fstr(loc.locationname)}</p>
|
||||
<p className="text-[10px] text-zinc-500 mt-0.5 flex items-center gap-1">
|
||||
<MapPin size={10} className="shrink-0 text-zinc-400" />
|
||||
<span className="truncate">{fstr(loc.suburb)}, {fstr(loc.city)} {fstr(loc.postcode)}</span>
|
||||
</p>
|
||||
</div>
|
||||
<span className={`shrink-0 px-1.5 py-0.5 rounded text-[9px] font-bold uppercase ${
|
||||
fstr(loc.status).toLowerCase() === 'active'
|
||||
? 'text-emerald-600 bg-emerald-50 border border-emerald-100'
|
||||
: 'text-zinc-500 bg-zinc-100'
|
||||
}`}>
|
||||
{fstr(loc.status) || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 mt-sm text-center">
|
||||
<div className="bg-white border border-[#e2e8f0]/60 rounded p-1">
|
||||
<p className="text-[9px] text-zinc-400 uppercase font-bold">Hours</p>
|
||||
<p className="font-mono font-semibold text-zinc-700 text-[10px] mt-0.5">
|
||||
{fstr(loc.opentime).slice(11, 16) || '—'}–{fstr(loc.closetime).slice(11, 16) || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white border border-[#e2e8f0]/60 rounded p-1">
|
||||
<p className="text-[9px] text-zinc-400 uppercase font-bold">Radius</p>
|
||||
<p className="font-mono font-semibold text-zinc-700 text-[10px] mt-0.5">{fnum(loc.deliveryradius)} m</p>
|
||||
</div>
|
||||
<div className="bg-white border border-[#e2e8f0]/60 rounded p-1">
|
||||
<p className="text-[9px] text-zinc-400 uppercase font-bold">ETA</p>
|
||||
<p className="font-mono font-semibold text-zinc-700 text-[10px] mt-0.5">{fnum(loc.deliverymins)} min</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[10px] text-zinc-400">Outlets are read live from your tenant. Add or edit them in the Stores section.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'delivery' && (
|
||||
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl shadow-sm space-y-md">
|
||||
<span className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest block pb-xs border-b border-[#f1f5f9]">
|
||||
Delivery Settings
|
||||
</span>
|
||||
<div className="space-y-sm">
|
||||
<Row title="Default Delivery Charge" desc="Flat fee added to each delivery order.">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-zinc-400 font-bold">₹</span>
|
||||
<input type="number" min={0} value={form.deliveryCharge}
|
||||
onChange={(e) => set('deliveryCharge', Number(e.target.value))} className={numberInputCls} />
|
||||
</div>
|
||||
</Row>
|
||||
<Row title="Preparation Time" desc="Minutes a store needs before pickup.">
|
||||
<input type="number" min={0} value={form.prepMins}
|
||||
onChange={(e) => set('prepMins', Number(e.target.value))} className={numberInputCls} />
|
||||
</Row>
|
||||
<Row title="Delivery Window" desc="Target minutes from dispatch to doorstep.">
|
||||
<input type="number" min={0} value={form.deliveryWindowMins}
|
||||
onChange={(e) => set('deliveryWindowMins', Number(e.target.value))} className={numberInputCls} />
|
||||
</Row>
|
||||
<Row title="Cancellation Window" desc="Seconds a customer can cancel for free.">
|
||||
<input type="number" min={0} value={form.cancelWindowSecs}
|
||||
onChange={(e) => set('cancelWindowSecs', Number(e.target.value))} className={numberInputCls} />
|
||||
</Row>
|
||||
<Row title="Auto-assign Rider" desc="Automatically dispatch the nearest available rider.">
|
||||
<Toggle checked={form.autoAssignRider} onChange={() => set('autoAssignRider', !form.autoAssignRider)} />
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'payment' && (
|
||||
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl shadow-sm space-y-md">
|
||||
<span className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest block pb-xs border-b border-[#f1f5f9]">
|
||||
Payment & Tax
|
||||
</span>
|
||||
<div className="space-y-sm">
|
||||
<Row title="Default Tax Rate" desc="Applied to taxable catalogue items.">
|
||||
<div className="flex items-center gap-1">
|
||||
<input type="number" min={0} max={100} value={form.defaultTaxPercent}
|
||||
onChange={(e) => set('defaultTaxPercent', Number(e.target.value))} className={numberInputCls} />
|
||||
<span className="text-zinc-400 font-bold">%</span>
|
||||
</div>
|
||||
</Row>
|
||||
<Row title="Minimum Order Value" desc="Smallest order a customer can place.">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-zinc-400 font-bold">₹</span>
|
||||
<input type="number" min={0} value={form.minOrderValue}
|
||||
onChange={(e) => set('minOrderValue', Number(e.target.value))} className={numberInputCls} />
|
||||
</div>
|
||||
</Row>
|
||||
<Row title="Cash on Delivery" desc="Allow customers to pay on delivery.">
|
||||
<Toggle checked={form.codEnabled} onChange={() => set('codEnabled', !form.codEnabled)} />
|
||||
</Row>
|
||||
<Row title="Online Payments" desc="Accept UPI / card / wallet at checkout.">
|
||||
<Toggle checked={form.onlinePaymentEnabled} onChange={() => set('onlinePaymentEnabled', !form.onlinePaymentEnabled)} />
|
||||
</Row>
|
||||
<div className="p-sm bg-purple-50 border border-purple-100 rounded-lg text-[#581c87] text-[11px] font-medium">
|
||||
Live tenant payment configuration code: <strong>{fnum(tenant?.paymenttype) || '—'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'preferences' && (
|
||||
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl shadow-sm space-y-md">
|
||||
<span className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest block pb-xs border-b border-[#f1f5f9]">
|
||||
Workspace Preferences
|
||||
</span>
|
||||
<div className="space-y-sm">
|
||||
<Row title="Default Region" desc="Region applied to new outlets and reports.">
|
||||
<input type="text" value={form.defaultRegion}
|
||||
onChange={(e) => set('defaultRegion', e.target.value)} className={`${numberInputCls} w-40 text-left`} />
|
||||
</Row>
|
||||
<Row title="Default Role for New Users" desc="Pre-selected role in the Add User dialog.">
|
||||
<select value={form.defaultNewUserRole}
|
||||
onChange={(e) => set('defaultNewUserRole', Number(e.target.value))} className={selectCls}>
|
||||
{roleOptions.map((r) => (
|
||||
<option key={r} value={r}>{roleName(r)}</option>
|
||||
))}
|
||||
</select>
|
||||
</Row>
|
||||
<Row title="Data Sync Interval" desc="How often live data refreshes from the API.">
|
||||
<select value={form.syncInterval}
|
||||
onChange={(e) => set('syncInterval', Number(e.target.value))} className={selectCls}>
|
||||
<option value={1}>Every 1 min</option>
|
||||
<option value={5}>Every 5 mins</option>
|
||||
<option value={15}>Every 15 mins</option>
|
||||
<option value={30}>Every 30 mins</option>
|
||||
</select>
|
||||
</Row>
|
||||
<Row title="Order Notifications" desc="Alert on every new incoming order.">
|
||||
<Toggle checked={form.orderNotifications} onChange={() => set('orderNotifications', !form.orderNotifications)} />
|
||||
</Row>
|
||||
<Row title="Low-stock Alerts" desc="Notify when an SKU drops below threshold.">
|
||||
<Toggle checked={form.lowStockAlerts} onChange={() => set('lowStockAlerts', !form.lowStockAlerts)} />
|
||||
</Row>
|
||||
<Row title="Daily Summary Email" desc="Email a closing-hours performance digest.">
|
||||
<Toggle checked={form.dailySummaryEmail} onChange={() => set('dailySummaryEmail', !form.dailySummaryEmail)} />
|
||||
</Row>
|
||||
<Row title="Sandbox Mode" desc="Simulate warning states without affecting live ops.">
|
||||
<Toggle checked={form.sandboxMode} onChange={() => set('sandboxMode', !form.sandboxMode)} />
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save / Reset — lives with the settings card, not pinned to the screen */}
|
||||
<div className="bg-white border border-[#e2e8f0] rounded-xl p-md shadow-sm flex flex-col sm:flex-row sm:items-center justify-between gap-sm">
|
||||
<span className={`text-xs font-medium ${dirty ? 'text-amber-600' : 'text-zinc-400'}`}>
|
||||
{dirty ? '● You have unsaved changes' : 'All changes saved'}
|
||||
</span>
|
||||
<div className="flex gap-sm">
|
||||
<button
|
||||
onClick={handleReset}
|
||||
disabled={!dirty}
|
||||
className="px-4 py-2 border border-[#e2e8f0] rounded-lg text-xs font-semibold text-zinc-600 hover:bg-zinc-50 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
|
||||
>
|
||||
<RotateCcw size={13} /> Reset
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!dirty}
|
||||
className="px-4 py-2 bg-[#581c87] text-white rounded-lg text-xs font-bold hover:bg-purple-800 cursor-pointer shadow-sm disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
|
||||
>
|
||||
<Check size={13} /> Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div className="fixed bottom-md right-md z-[130] bg-[#0f172a] text-white px-4 py-2.5 rounded-lg shadow-2xl flex items-center gap-2 text-xs font-semibold animate-in slide-in-from-bottom-2 fade-in duration-200">
|
||||
<CheckCircle2 size={15} className="text-emerald-400" />
|
||||
{toast}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
src/components/Sidebar.tsx
Normal file
73
src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Store,
|
||||
Layers,
|
||||
ShoppingBag,
|
||||
Users,
|
||||
Settings
|
||||
} from 'lucide-react';
|
||||
import { MainSection } from '../types';
|
||||
|
||||
interface SidebarProps {
|
||||
currentSection: MainSection;
|
||||
setCurrentSection: (section: MainSection) => void;
|
||||
isCoimbatoreView: boolean;
|
||||
setIsCoimbatoreView: (val: boolean) => void;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export default function Sidebar({
|
||||
currentSection,
|
||||
setCurrentSection,
|
||||
isCoimbatoreView,
|
||||
setIsCoimbatoreView,
|
||||
isOpen
|
||||
}: SidebarProps) {
|
||||
// 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: 'Inventory Catalog', icon: Layers },
|
||||
{ id: 'users' as MainSection, label: 'Users', icon: Users },
|
||||
{ id: 'settings' as MainSection, label: 'Settings', icon: Settings }
|
||||
];
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`fixed left-0 top-0 h-screen bg-[#581c87] border-r border-[#4c1d95] text-white flex-col py-xl pt-24 z-40 hidden md:flex transition-all duration-300 ${
|
||||
isOpen ? 'w-64' : 'w-20'
|
||||
}`}
|
||||
>
|
||||
{/* Main Navigation Sidebar Links */}
|
||||
<nav className="flex-1 space-y-1 overflow-y-auto px-xs">
|
||||
{navItems.map((item) => {
|
||||
const IconComponent = item.icon;
|
||||
const isActive = currentSection === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setCurrentSection(item.id)}
|
||||
title={item.label}
|
||||
className={`w-full flex items-center py-3 rounded-lg text-left transition-all duration-200 cursor-pointer ${
|
||||
isOpen ? 'gap-md px-md' : 'justify-center px-0'
|
||||
} ${
|
||||
isActive
|
||||
? 'bg-purple-800 text-white font-semibold' + (isOpen ? ' border-l-4 border-white' : '')
|
||||
: 'text-purple-200 hover:bg-purple-800/60 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<IconComponent size={18} className={isActive ? 'text-white' : 'text-purple-300'} />
|
||||
{isOpen && <span className="font-sans text-sm font-medium">{item.label}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
1549
src/components/StoreDetailView.tsx
Normal file
1549
src/components/StoreDetailView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user