feat: relocate orders and deliveries to store console & polish store cover images

This commit is contained in:
Suriya
2026-06-03 18:20:43 +05:30
commit 6eaeb5c4a7
32 changed files with 13430 additions and 0 deletions

15
.env.example Normal file
View File

@@ -0,0 +1,15 @@
# GEMINI_API_KEY: Required for Gemini AI API calls.
# AI Studio automatically injects this at runtime from user secrets.
# Users configure this via the Secrets panel in the AI Studio UI.
GEMINI_API_KEY="MY_GEMINI_API_KEY"
# APP_URL: The URL where this applet is hosted.
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints.
APP_URL="MY_APP_URL"
# HASURA_ADMIN_SECRET: Admin secret for the workolik.com Hasura REST API.
# Used ONLY by the Vite dev-server proxy (vite.config.ts) to inject the
# x-hasura-admin-secret header server-side. NOT prefixed with VITE_, so it
# never ends up in the client bundle. Copy this file to .env and set the value.
HASURA_ADMIN_SECRET="MY_HASURA_ADMIN_SECRET"

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
build/
dist/
coverage/
.DS_Store
*.log
.env*
!.env.example

20
README.md Normal file
View File

@@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://ai.google.dev/static/site-assets/images/share-ais-513315318.png" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/0406e53f-aca4-4047-80a3-dbe279cebc0b
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

14
index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/favicon.png" />
<title>nearledaily</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6
metadata.json Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "nearledaily",
"description": "Real-time business intelligence dashboard and unified operations command center for retail networks.",
"requestFramePermissions": [],
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
}

4374
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "react-example",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port=3000 --host=0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"clean": "rm -rf dist server.js",
"lint": "tsc --noEmit"
},
"dependencies": {
"@google/genai": "^2.4.0",
"@tailwindcss/vite": "^4.1.14",
"@tanstack/react-query": "^5.101.0",
"@vitejs/plugin-react": "^5.0.4",
"dotenv": "^17.2.3",
"express": "^4.21.2",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"vite": "^6.2.3"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.21",
"esbuild": "^0.25.0",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.3"
}
}

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

1029
src/App.tsx Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 827 KiB

View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

File diff suppressed because it is too large Load Diff

433
src/data.ts Normal file
View File

@@ -0,0 +1,433 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import {
KPICardData,
RevenueHeatmapDay,
LeaderboardNode,
ProductMatrixItem,
InventoryItem,
OrderItem,
ImportLog,
OperationalAlert,
RegionalPerformance,
CustomerOrder
} from './types';
// Dashboard Screen KPIs (Tamil Nadu Coimbatore Centric)
export const dashboardKPIs: KPICardData[] = [
{
title: 'TOTAL ORDERS',
value: '4,921',
subtext: 'Last 24 Hours',
sparkline: [50, 40, 60, 45, 80, 55, 65],
badge: '1.4k'
},
{
title: "TODAY'S REVENUE",
value: '₹4.2L',
subtext: 'Settled Funds (IST)',
trend: '+8.1%',
isPositive: true,
sparkline: [35, 55, 45, 70, 60, 90, 85]
},
{
title: 'MONTHLY PROFIT',
value: '₹18.9L',
subtext: 'EBITDA Margin 22%',
trend: '-0.4%',
isPositive: false,
sparkline: [80, 75, 70, 65, 55, 60, 45]
},
{
title: 'DELIVERED',
value: '4,102',
subtext: 'In-Full-On-Time (CBE)',
trend: '98.2%',
isPositive: true,
sparkline: [60, 70, 68, 85, 78, 92, 95]
}
];
// Reports Screen KPIs (4 cards - Tamil Nadu Coimbatore Centric)
export const reportsKPIs: KPICardData[] = [
{
title: 'Gross Revenue',
value: '₹12,48,302.00',
subtext: 'Vs. Previous Quarter: ₹10.9L',
trend: '+14.2%',
isPositive: true,
sparkline: [40, 48, 52, 65, 72, 78, 90]
},
{
title: 'Net Margin %',
value: '28.4%',
subtext: 'Target threshold: 30.0%',
trend: '-0.8%',
isPositive: false,
sparkline: [75, 72, 68, 64, 60, 62, 58]
},
{
title: 'Active Inventory',
value: '48.2k',
subtext: 'Groceries units across TN hubs',
trend: '+5.1%',
isPositive: true,
sparkline: [55, 58, 61, 60, 64, 69, 73]
},
{
title: 'Staff Efficiency',
value: '94.1%',
subtext: 'Active utilization rate (CBE)',
trend: '+2.4%',
isPositive: true,
sparkline: [82, 85, 84, 88, 91, 93, 94]
}
];
// Heatmap Data (Hourly Revenue Distribution)
export const heatmapData: RevenueHeatmapDay[] = [
{
day: 'Mon',
hours: {
'08:00': 1.2,
'10:00': 4.5,
'12:00': 8.9,
'14:00': 5.2,
'16:00': 4.8,
'18:00': 9.1,
'20:00': 3.2
}
},
{
day: 'Tue',
hours: {
'08:00': 1.1,
'10:00': 3.8,
'12:00': 5.9,
'14:00': 7.2,
'16:00': 4.1,
'18:00': 8.4,
'20:00': 2.9
}
},
{
day: 'Wed',
hours: {
'08:00': 1.5,
'10:00': 4.2,
'12:00': 9.4,
'14:00': 10.1,
'16:00': 5.1,
'18:00': 4.2,
'20:00': 1.2
}
},
{
day: 'Thu',
hours: {
'08:00': 0.9,
'10:00': 2.2,
'12:00': 4.8,
'14:00': 5.5,
'16:00': 8.2,
'18:00': 7.9,
'20:00': 3.4
}
},
{
day: 'Fri',
hours: {
'08:00': 2.2,
'10:00': 7.8,
'12:00': 11.4,
'14:00': 12.2,
'16:00': 9.8,
'18:00': 10.5,
'20:00': 5.2
}
}
];
// Leaderboards (Top Performing State & Coimbatore Nodes)
export const globalLeaderboard: LeaderboardNode[] = [
{ rank: '01', name: 'Chennai - T-Nagar', percentage: 94, revenue: '₹2.4L' },
{ rank: '02', name: 'Coimbatore - RS Puram Main', percentage: 88, revenue: '₹2.18L' },
{ rank: '03', name: 'Madurai - KK Nagar', percentage: 82, revenue: '₹1.94L' },
{ rank: '04', name: 'Trichy - Thillai Nagar', percentage: 76, revenue: '₹1.81L' }
];
export const regionalLeaderboard: LeaderboardNode[] = [
{ rank: '01', name: 'RS Puram - Main St', percentage: 94, revenue: '₹2.4L' },
{ rank: '02', name: 'Gandhipuram Cross Cut', percentage: 88, revenue: '₹2.1L' },
{ rank: '03', name: 'Peelamedu Avinashi Rd', percentage: 82, revenue: '₹1.9L' },
{ rank: '04', name: 'Town Hall Bazaar', percentage: 76, revenue: '₹1.8L' }
];
// Product Matrix (Detailed Groceries/Retail Items Matrix)
export const initialProducts: ProductMatrixItem[] = [
{
id: '1',
name: 'Ponni Raw Rice Premium 10kg',
sku: 'PONNI-RICE-10K',
unitsSold: 12402,
revenue: 868140,
stockStatus: 'Healthy',
trend: 'up',
image: 'https://images.unsplash.com/photo-1586201375761-83865001e31c?auto=format&fit=crop&w=150&q=80',
category: 'Staples / Rice',
exposure: '34/42 Stores',
verified: true
},
{
id: '2',
name: 'Idhayam Pure Sesame Oil 1L',
sku: 'IDH-SESAME-1L',
unitsSold: 8912,
revenue: 1480000,
stockStatus: 'Low Stock',
trend: 'flat',
image: 'https://images.unsplash.com/photo-1474979266404-7eaacbcd87c5?auto=format&fit=crop&w=150&q=80',
category: 'Groceries / Oils',
exposure: '40/42 Stores',
verified: true
},
{
id: '3',
name: 'Narasus Filter Coffee Powder 500g',
sku: 'NARASUS-COF-500',
unitsSold: 15221,
revenue: 456630,
stockStatus: 'Healthy',
trend: 'up',
image: 'https://images.unsplash.com/photo-1514432324607-a09d9b4aefdd?auto=format&fit=crop&w=150&q=80',
category: 'Beverages / Coffee',
exposure: '42/42 Stores',
verified: true
},
{
id: '4',
name: 'Ooty Fresh Quality Carrots 1kg',
sku: 'OOTY-CARROT-1KG',
unitsSold: 4120,
revenue: 123600,
stockStatus: 'Critical',
trend: 'down',
image: 'https://images.unsplash.com/photo-1598170845058-32b9d6a5da37?auto=format&fit=crop&w=150&q=80',
category: 'Fresh Produce / Veg',
exposure: '32/42 Stores',
verified: false
}
];
// Inventory lists (Coimbatore Warehouse Region Centric)
export const initialInventory: InventoryItem[] = [
{
sku: 'RICE-PN-50',
name: 'Premium Ponni Rice Bag 25kg',
warehouse: 'RS Puram Hub (CBE-01)',
stockLevel: 1402,
maxCapacity: 2000,
status: 'Optimal',
region: 'CBE-NORTH'
},
{
sku: 'ATTA-ASH-10',
name: 'Aashirvaad Chakki Atta 10kg',
warehouse: 'Gandhipuram Warehouse (CBE-04)',
stockLevel: 12,
maxCapacity: 100,
status: 'Critical',
region: 'CBE-SOUTH'
},
{
sku: 'OIL-IDH-05',
name: 'Idhayam Sesame Oil Can 5L',
warehouse: 'Peelamedu Sort Facility (CBE-09)',
stockLevel: 45,
maxCapacity: 120,
status: 'Low Stock',
region: 'CBE-EAST'
},
{
sku: 'COF-NAR-01',
name: 'Narasus Filter Coffee 1kg Pack',
warehouse: 'Pollachi Road Depot (CBE-15)',
stockLevel: 890,
maxCapacity: 1000,
status: 'Optimal',
region: 'CBE-WEST'
},
{
sku: 'MILK-AAV-50',
name: 'Aavin Premium Pouch Milk 500ml',
warehouse: 'Tiruppur Junction Depot (TPR-02)',
stockLevel: 18,
maxCapacity: 150,
status: 'Low Stock',
region: 'TIRUPPUR'
}
];
// Order Items list
export const initialOrders: OrderItem[] = [
{
id: '#ORD-902341',
store: 'RS Puram Terminal',
amount: 12490.00,
time: '14:22 PM (IST)',
status: 'SHIPPED'
},
{
id: '#ORD-902339',
store: 'Gandhipuram Outlet 2',
amount: 840.50,
time: '13:05 PM (IST)',
status: 'FLAGGED'
},
{
id: '#ORD-902312',
store: 'Peelamedu Center',
amount: 24200.00,
time: '11:15 AM (IST)',
status: 'SHIPPED'
},
{
id: '#ORD-901995',
store: 'Saravanampatti Hub',
amount: 5120.00,
time: '09:40 AM (IST)',
status: 'PROCESSING'
}
];
// Import log items list
export const initialImportLogs: ImportLog[] = [
{
timestamp: 'May 31, 2026 09:12',
batchRef: '#IMP_0921_A',
type: 'Inventory Sync',
source: 'ERP Export',
result: 'SUCCESS (421 Rows)',
status: 'SUCCESS'
},
{
timestamp: 'May 30, 2026 17:45',
batchRef: '#IMP_0920_Z',
type: 'Catalogue Meta',
source: 'Marketing API',
result: 'FAILED (Invalid API Key)',
status: 'FAILED'
},
{
timestamp: 'May 28, 2026 10:30',
batchRef: '#IMP_0899_C',
type: 'Logistics Matrix',
source: 'Manual Upload',
result: 'SUCCESS (1,500 Rows)',
status: 'SUCCESS'
}
];
// Operational Alerts (Tamil Nadu Coimbatore Centric)
export const operationalAlerts: OperationalAlert[] = [
{
id: 'alert-1',
type: 'critical',
title: 'Stock Critical: RS Puram Store',
details: 'Fresh dairy and milk category inventory fell below 5% at 08:24 AM.',
time: '08:24 AM'
},
{
id: 'alert-2',
type: 'warning',
title: 'High Latency Warning',
details: 'Coimbatore Hub Server API latency spiked to 640ms in grocery billing services.',
time: '07:15 AM'
},
{
id: 'alert-3',
type: 'info',
title: 'Node Rebalance Completed',
details: 'Peelamedu sort facility rebalanced 4,500 units of Ponni Rice to Gandhipuram nodes.',
time: '05:30 AM'
}
];
// Regional Performance (Coimbatore Zones)
export const regionalPerformances: RegionalPerformance[] = [
{ region: 'Coimbatore North', sales: '₹42.4L', revenueValue: 42.4, maxTarget: 50 },
{ region: 'Coimbatore South', sales: '₹38.2L', revenueValue: 38.2, maxTarget: 50 },
{ region: 'Coimbatore East & Peelamedu', sales: '₹29.1L', revenueValue: 29.1, maxTarget: 50 },
{ region: 'Tiruppur Regional Hub', sales: '₹14.5L', revenueValue: 14.5, maxTarget: 50 }
];
// Initial Customer Orders Dataset
export const initialCustomerOrders: CustomerOrder[] = [
{
id: 'DM-ORD-2091',
customerName: 'Meenakshi Sundaram',
phone: '+91 94432 18942',
address: 'Plot 4, Lakshmipuram Ext, RS Puram, Coimbatore - 641002',
items: [
{ name: 'Ponni Raw Rice Premium 10kg', quantity: 1, price: 680 },
{ name: 'Idhayam Pure Sesame Oil 1L', quantity: 2, price: 340 },
{ name: 'Narasus Filter Coffee Powder 500g', quantity: 1, price: 195 }
],
amount: 1555,
time: '14:24 PM',
status: 'OUT_FOR_DELIVERY',
assignedRider: 'Karthikeyan Radhakrishnan',
hub: 'RS Puram Hub'
},
{
id: 'DM-ORD-2092',
customerName: 'Senthil Kumar VSD',
phone: '+91 98421 00234',
address: 'Flat 2C, Whispering Palms, Avinashi Road, Peelamedu - 641004',
items: [
{ name: 'Premium Ponni Rice Bag 25kg', quantity: 1, price: 1600 },
{ name: 'Aavin Premium Milk 500ml', quantity: 4, price: 28 },
{ name: 'Ooty Fresh Quality Carrots 1kg', quantity: 2, price: 60 }
],
amount: 1832,
time: '14:10 PM',
status: 'DELIVERED',
assignedRider: 'Arun Kumar Chinnasamy',
hub: 'Peelamedu Sort Center'
},
{
id: 'DM-ORD-2093',
customerName: 'Kavitha Ramaswamy',
phone: '+91 90035 88921',
address: 'No 15, Cross Cut Road, Gandhipuram, Coimbatore - 641012',
items: [
{ name: 'Aashirvaad Chakki Atta 10kg', quantity: 2, price: 440 },
{ name: 'Organic Turmeric Powder 200g', quantity: 1, price: 90 },
{ name: 'Pure Cow Ghee 500ml', quantity: 1, price: 320 }
],
amount: 1290,
time: '13:50 PM',
status: 'CONFIRMED',
assignedRider: 'Suresh Balasubramaniam',
hub: 'Gandhipuram Cross Rd'
},
{
id: 'DM-ORD-2094',
customerName: 'Dr. Anand Selvapandian',
phone: '+91 97890 22104',
address: 'Villa 12, Sobha Elanza, Sathy Road, Saravanampatti - 641035',
items: [
{ name: 'Narasus Filter Coffee Powder 500g', quantity: 2, price: 195 },
{ name: 'Idhayam Pure Sesame Oil 1L', quantity: 1, price: 340 }
],
amount: 730,
time: '13:15 PM',
status: 'PROCESSING',
assignedRider: 'Pending Assignment',
hub: 'Saravanampatti Store CBE'
}
];

15
src/index.css Normal file
View File

@@ -0,0 +1,15 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap');
@import "tailwindcss";
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
--spacing-gutter: 20px;
--spacing-container-margin: 24px;
}

25
src/main.tsx Normal file
View File

@@ -0,0 +1,25 @@
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
import App from './App.tsx';
import './index.css';
// Single shared query client. Sensible defaults for a dashboard: cache for a
// minute, one retry, and no refetch storm when the window regains focus.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
retry: 1,
refetchOnWindowFocus: false,
},
},
});
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
);

420
src/services/api.ts Normal file
View File

@@ -0,0 +1,420 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Workolik Hasura REST client — the `/api/rest/*` surface documented at
* https://developer.nearledaily.com (Users, Orders, Tenants, Products,
* Apps & Locations, Partners). The "Mobile" section is intentionally omitted.
*
* All requests go through the Vite dev proxy at `/hasura/*`, which rewrites to
* `https://api.workolik.com/api/rest/*` and injects the `x-hasura-admin-secret`
* header server-side (see vite.config.ts). The secret never reaches the browser.
*
* Components should not call these directly — use the TanStack Query hooks in
* `./queries`, which add caching, dedup, and loading/error state.
*/
const HASURA_BASE = '/hasura';
/** Tenant whose live data the dashboard displays. */
export const DEFAULT_TENANT_ID = 1087;
/** Order-module config the tenant's order summary is computed against. */
export const DEFAULT_CONFIG_ID = 1;
type QueryParams = Record<string, string | number | undefined | null>;
async function hasuraGet<T = unknown>(endpoint: string, params: QueryParams = {}): Promise<T> {
const qs = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== '') qs.append(k, String(v));
});
const query = qs.toString();
const res = await fetch(`${HASURA_BASE}/${endpoint}${query ? `?${query}` : ''}`, {
headers: { Accept: 'application/json' },
});
if (!res.ok) {
throw new Error(`Hasura ${endpoint} failed: ${res.status} ${res.statusText}`);
}
return res.json() as Promise<T>;
}
/**
* Hasura REST responses come back in a few shapes depending on the route:
* - a bare array: [ {...}, {...} ]
* - an envelope with `details`: { details: [...] } or { details: {...} }
* - an envelope keyed by the table: { tenants: [...] }, { orders: [...] }
* This normalizes any of those to an array of rows.
*/
export function toRows<T = Record<string, unknown>>(json: unknown): T[] {
if (Array.isArray(json)) return json as T[];
if (json && typeof json === 'object') {
const obj = json as Record<string, unknown>;
if (Array.isArray(obj.details)) return obj.details as T[];
if (obj.details && typeof obj.details === 'object') return [obj.details as T];
if (Array.isArray(obj.data)) return obj.data as T[];
// Envelope keyed by the route/table name (Hasura's default for REST endpoints).
const firstArray = Object.values(obj).find((v) => Array.isArray(v));
if (Array.isArray(firstArray)) return firstArray as T[];
}
return [];
}
function firstRow<T = Record<string, unknown>>(json: unknown): T | null {
const rows = toRows<T>(json);
return rows.length ? rows[0] : null;
}
function n(v: unknown): number {
const num = typeof v === 'number' ? v : Number(v);
return Number.isFinite(num) ? num : 0;
}
type Row = Record<string, unknown>;
/** ISO datetime helpers for endpoints that expect full timestamps. */
export const startOfDay = (d: string) => (d.includes('T') ? d : `${d}T00:00:00`);
export const endOfDay = (d: string) => (d.includes('T') ? d : `${d}T23:59:59`);
// ════════════════════════════════════════════════════════════════════════════
// USERS
// ════════════════════════════════════════════════════════════════════════════
/** /getusers?roleid=&tenantid=&limit=&offset= — list users in the system. */
export async function getUsers(opts: {
tenantid: number;
roleid?: number;
limit?: number;
offset?: number;
}): Promise<Row[]> {
return toRows(
await hasuraGet('getusers', {
tenantid: opts.tenantid,
roleid: opts.roleid,
limit: opts.limit ?? 10,
offset: opts.offset ?? 0,
}),
);
}
/** /getapproles?configid= — all application roles for a config. */
export async function getAppRoles(configid: number = 15): Promise<Row[]> {
return toRows(await hasuraGet('getapproles', { configid }));
}
// ════════════════════════════════════════════════════════════════════════════
// ORDERS
// ════════════════════════════════════════════════════════════════════════════
/**
* /getorders?start=&end=&status=&limit=&offset= — detailed orders in a window.
* NOTE: this is a global feed (no tenant filter accepted) and `status` is required.
*/
export async function getOrders(opts: {
start: string; // ISO with time
end: string;
status: string; // e.g. 'delivered' | 'created' | 'cancelled'
limit?: number;
offset?: number;
}): Promise<Row[]> {
return toRows(
await hasuraGet('getorders', {
start: startOfDay(opts.start),
end: endOfDay(opts.end),
status: opts.status,
limit: opts.limit ?? 10,
offset: opts.offset ?? 0,
}),
);
}
export interface OrderSummary {
total: number;
created: number;
pending: number;
processing: number;
delivered: number;
cancelled: number;
tenantid?: number;
tenantname?: string;
}
/** /getordersummary?tenantid=&configid=&fromdate=&todate= — aggregated order counts. */
export async function getOrderSummary(
tenantid: number,
fromdate: string,
todate: string,
configid: number = DEFAULT_CONFIG_ID,
): Promise<OrderSummary | null> {
// Workolik returns counts as { total: { aggregate: { count: N } }, ... } plus a
// `tenants` array. (The fiesta variant returns flat numbers.) Read both shapes.
const json = await hasuraGet<Row>('getordersummary/', { tenantid, fromdate, todate, configid });
if (!json || typeof json !== 'object') return null;
const count = (key: string): number => {
const node = (json as Row)[key];
if (node && typeof node === 'object' && 'aggregate' in (node as Row)) {
const agg = (node as { aggregate?: { count?: unknown } }).aggregate;
return n(agg?.count);
}
return n(node);
};
const tenants = (json as { tenants?: Row[] }).tenants;
const tenantRow = Array.isArray(tenants) && tenants.length ? tenants[0] : null;
return {
total: count('total'),
created: count('created'),
pending: count('pending'),
processing: count('processing'),
delivered: count('delivered'),
cancelled: count('cancelled'),
tenantid: tenantRow?.tenantid != null ? n(tenantRow.tenantid) : undefined,
tenantname: typeof tenantRow?.tenantname === 'string' ? (tenantRow.tenantname as string) : undefined,
};
}
// ════════════════════════════════════════════════════════════════════════════
// TENANTS
// ════════════════════════════════════════════════════════════════════════════
/** /gettenantinfo?tenantid= — info about a tenant. */
export async function getTenantInfo(tenantid: number): Promise<Row | null> {
return firstRow(await hasuraGet('gettenantinfo', { tenantid }));
}
/** /gettenantlocations?tenantid= — physical locations linked to a tenant. */
export async function getTenantLocations(tenantid: number): Promise<Row[]> {
return toRows(await hasuraGet('gettenantlocations', { tenantid }));
}
/** /getcustomersbytenant?tenantid=&limit=&offset= — customers for a tenant. */
export async function getCustomersByTenant(opts: {
tenantid: number;
limit?: number;
offset?: number;
}): Promise<Row[]> {
return toRows(
await hasuraGet('getcustomersbytenant', {
tenantid: opts.tenantid,
limit: opts.limit ?? 10,
offset: opts.offset ?? 0,
}),
);
}
/** /gettenantcustomers?tenantid=&locationid=&limit=&offset= — customers under a tenant+location. */
export async function getTenantCustomers(opts: {
tenantid: number;
locationid: number;
limit?: number;
offset?: number;
}): Promise<Row[]> {
return toRows(
await hasuraGet('gettenantcustomers', {
tenantid: opts.tenantid,
locationid: opts.locationid,
limit: opts.limit ?? 10,
offset: opts.offset ?? 0,
}),
);
}
/** /gettenantdeliveries?tenantid=&status=&fromdate=&todate=&keyword=&limit=&offset= */
export async function getTenantDeliveries(opts: {
tenantid: number;
status?: string;
fromdate: string; // ISO with time
todate: string;
keyword?: string;
limit?: number;
offset?: number;
}): Promise<Row[]> {
return toRows(
await hasuraGet('gettenantdeliveries', {
tenantid: opts.tenantid,
status: opts.status,
fromdate: startOfDay(opts.fromdate),
todate: endOfDay(opts.todate),
keyword: opts.keyword ?? '%',
limit: opts.limit ?? 10,
offset: opts.offset ?? 0,
}),
);
}
// ════════════════════════════════════════════════════════════════════════════
// PRODUCTS
// ════════════════════════════════════════════════════════════════════════════
/** /getproductcategories?moduleid= — root product categories for a module. */
export async function getProductCategories(moduleid: number): Promise<Row[]> {
return toRows(await hasuraGet('getproductcategories', { moduleid }));
}
/** /getsubcategory?moduleid=&categoryid= — a subcategory under a root category. */
export async function getSubcategory(moduleid: number, categoryid: number): Promise<Row[]> {
return toRows(await hasuraGet('getsubcategory', { moduleid, categoryid }));
}
/** /getproductsubcategories?categoryid= — all subcategories under a root category. */
export async function getProductSubcategories(categoryid: number): Promise<Row[]> {
return toRows(await hasuraGet('getproductsubcategories', { categoryid }));
}
/** /getproductvariants?tenantid=&subcategoryid= — product variants for a tenant. */
export async function getProductVariants(tenantid: number, subcategoryid: number): Promise<Row[]> {
return toRows(await hasuraGet('getproductvariants', { tenantid, subcategoryid }));
}
/** /getstockstatement?tenantid=&locationid=&subcategoryid=&keyword=&limit=&offset= */
export async function getStockStatement(opts: {
tenantid: number;
locationid: number;
subcategoryid?: number;
keyword?: string;
limit?: number;
offset?: number;
}): Promise<Row[]> {
return toRows(
await hasuraGet('getstockstatement', {
tenantid: opts.tenantid,
locationid: opts.locationid,
subcategoryid: opts.subcategoryid,
keyword: opts.keyword ?? '%',
limit: opts.limit ?? 10,
offset: opts.offset ?? 0,
}),
);
}
/** /getproductscount?tenantid=&categoryid=&subcategoryid= — available/out-of-stock counts. */
export async function getProductsCount(opts: {
tenantid: number;
categoryid: number;
subcategoryid?: number;
}): Promise<Row | null> {
return firstRow(
await hasuraGet('getproductscount', {
tenantid: opts.tenantid,
categoryid: opts.categoryid,
subcategoryid: opts.subcategoryid,
}),
);
}
// ════════════════════════════════════════════════════════════════════════════
// APPS & LOCATIONS
// ════════════════════════════════════════════════════════════════════════════
/** /getapplocations — all currently active locations globally. */
export async function getAppLocations(): Promise<Row[]> {
return toRows(await hasuraGet('getapplocations'));
}
/** /getapplocationconfig — global configuration for application locations. */
export async function getAppLocationConfig(): Promise<Row[]> {
return toRows(await hasuraGet('getapplocationconfig'));
}
/** /getapptypes?tag= — application types grouped by a tag (e.g. 'partner', 'DELIVERY'). */
export async function getAppTypes(tag: string): Promise<Row[]> {
return toRows(await hasuraGet('getapptypes', { tag }));
}
// ════════════════════════════════════════════════════════════════════════════
// PARTNERS
// ════════════════════════════════════════════════════════════════════════════
/** /getpartners?applocationid=&partnerid=&limit=&offset= — active partners. */
export async function getPartners(opts: {
applocationid: number;
partnerid?: number;
limit?: number;
offset?: number;
}): Promise<Row[]> {
return toRows(
await hasuraGet('getpartners', {
applocationid: opts.applocationid,
partnerid: opts.partnerid ?? 0,
limit: opts.limit ?? 10,
offset: opts.offset ?? 0,
}),
);
}
/** /getridershifts?applocationid= — historic + active rider shift records. */
export async function getRiderShifts(applocationid: number): Promise<Row[]> {
return toRows(await hasuraGet('getridershifts', { applocationid }));
}
// ════════════════════════════════════════════════════════════════════════════
// INVOICE (used by the dashboard; not part of a public docs section)
// ════════════════════════════════════════════════════════════════════════════
export interface InvoiceInsight {
revenue: number;
profit: number;
raw: Row;
}
/** /getinvoiceinsight?tenantid= — revenue / financial roll-up for a tenant. */
export async function getInvoiceInsight(tenantid: number): Promise<InvoiceInsight | null> {
const row = firstRow<Row>(await hasuraGet('getinvoiceinsight', { tenantid }));
if (!row) return null;
const pick = (...keys: string[]) => {
for (const k of keys) {
if (row[k] != null && row[k] !== '') return n(row[k]);
}
return 0;
};
return {
revenue: pick('revenue', 'totalrevenue', 'grossrevenue', 'sales', 'totalsales', 'totalamount'),
profit: pick('profit', 'netprofit', 'margin', 'totalprofit'),
raw: row,
};
}
// ════════════════════════════════════════════════════════════════════════════
// DASHBOARD AGGREGATE
// ════════════════════════════════════════════════════════════════════════════
export interface DashboardKpis {
totalOrders: number;
delivered: number;
// null when the tenant has no invoice/revenue records (so the card can show "—"
// instead of a misleading ₹0).
todaysRevenue: number | null;
monthlyProfit: number | null;
tenantName?: string;
}
const ymd = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
/**
* Pulls the four dashboard KPIs from live data for the given tenant:
* - Total Orders ← month-to-date order summary `.total`
* - Delivered ← month-to-date order summary `.delivered`
* - Today's Revenue ← invoice insight revenue (null if the tenant has none)
* - Monthly Profit ← invoice insight profit (null if the tenant has none)
*/
export async function getDashboardKpis(tenantid: number = DEFAULT_TENANT_ID): Promise<DashboardKpis> {
const today = new Date();
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
const [monthSummary, insight] = await Promise.all([
getOrderSummary(tenantid, ymd(monthStart), ymd(today)),
getInvoiceInsight(tenantid).catch(() => null),
]);
return {
totalOrders: monthSummary?.total ?? 0,
delivered: monthSummary?.delivered ?? 0,
todaysRevenue: insight ? insight.revenue : null,
monthlyProfit: insight ? insight.profit : null,
tenantName: monthSummary?.tenantname,
};
}

462
src/services/fiestaApi.ts Normal file
View File

@@ -0,0 +1,462 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Fiesta REST client — the merchant-facing `live/api/v1/web/*` surface served by
* https://fiesta.nearle.app (documented at developer.nearledaily.com under the
* REST tab). This is the operational backend: order/delivery/location summaries,
* the deliveries board, riders, stock statements, and customers.
*
* Requests go through the Vite dev proxy at `/fiesta/*`, which forwards to
* `https://fiesta.nearle.app/*` (see vite.config.ts). Fiesta is CORS-enabled and
* needs no auth header for these read endpoints.
*
* This sits alongside `./api` (the Hasura/workolik REST surface the dashboard
* uses). Components should call the TanStack hooks in `./fiestaQueries`, not
* these functions directly.
*/
const FIESTA_BASE = '/fiesta/live/api/v1/web';
/** Tenant / location scope shared by the merchant console (Ragul Stores, Coimbatore). */
export const FIESTA_TENANT_ID = 1087;
export const FIESTA_APPLOCATION_ID = 1;
/** Primary outlet for this tenant — the one carrying live orders/stock. */
export const FIESTA_PRIMARY_LOCATION_ID = 1097;
export type Row = Record<string, unknown>;
type QueryParams = Record<string, string | number | undefined | null>;
async function fiestaGet<T = unknown>(endpoint: string, params: QueryParams = {}): Promise<T> {
const qs = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
// Fiesta requires some params to be present-but-empty (e.g. keyword=), so we
// keep empty strings and only drop undefined/null.
if (v !== undefined && v !== null) qs.append(k, String(v));
});
const query = qs.toString();
const res = await fetch(`${FIESTA_BASE}/${endpoint}${query ? `?${query}` : ''}`, {
headers: { Accept: 'application/json' },
});
if (!res.ok) {
throw new Error(`Fiesta ${endpoint} failed: ${res.status} ${res.statusText}`);
}
return res.json() as Promise<T>;
}
async function fiestaSend<T = unknown>(
endpoint: string,
method: 'POST' | 'PUT' | 'DELETE',
body?: unknown,
): Promise<T> {
const res = await fetch(`${FIESTA_BASE}/${endpoint}`, {
method,
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const json = (await res.json().catch(() => null)) as
| { message?: string; status?: boolean }
| null;
if (!res.ok || (json && json.status === false)) {
throw new Error(json?.message || `Fiesta ${endpoint} failed: ${res.status} ${res.statusText}`);
}
return json as T;
}
/**
* Fiesta envelopes responses as `{ code, details, message, status }`. `details`
* is usually an array of rows, sometimes a single object, sometimes null.
*/
export function toRows<T = Row>(json: unknown): T[] {
if (Array.isArray(json)) return json as T[];
if (json && typeof json === 'object') {
const d = (json as { details?: unknown }).details;
if (Array.isArray(d)) return d as T[];
if (d && typeof d === 'object') return [d as T];
}
return [];
}
export function firstRow<T = Row>(json: unknown): T | null {
const d = (json as { details?: unknown })?.details;
if (Array.isArray(d)) return (d.length ? (d[0] as T) : null);
if (d && typeof d === 'object') return d as T;
return null;
}
export function num(v: unknown): number {
const n = typeof v === 'number' ? v : Number(v);
return Number.isFinite(n) ? n : 0;
}
export const str = (v: unknown): string => (v == null ? '' : String(v));
/** Fiesta date params want a bare `YYYY-MM-DD`. */
export const ymd = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
// ════════════════════════════════════════════════════════════════════════════
// ORDERS
// ════════════════════════════════════════════════════════════════════════════
export interface FiestaOrderSummary {
total: number;
created: number;
pending: number;
processing: number;
delivered: number;
cancelled: number;
tenantid?: number;
tenantname?: string;
}
/** /orders/getordersummary?tenantid=&fromdate=&todate= — flat order counts. */
export async function getOrderSummary(
tenantid: number,
fromdate: string,
todate: string,
): Promise<FiestaOrderSummary | null> {
const row = firstRow<Row>(await fiestaGet('orders/getordersummary', { tenantid, fromdate, todate }));
if (!row) return null;
return {
total: num(row.total),
created: num(row.created),
pending: num(row.pending),
processing: num(row.processing),
delivered: num(row.delivered),
cancelled: num(row.cancelled),
tenantid: row.tenantid != null ? num(row.tenantid) : undefined,
tenantname: typeof row.tenantname === 'string' ? row.tenantname : undefined,
};
}
export interface FiestaLocationSummary {
locationid: number;
locationname: string;
total: number;
created: number;
pending: number;
processing: number;
delivered: number;
cancelled: number;
}
/** /orders/getlocationsummary?tenantid= — per-outlet order rollup. */
export async function getLocationSummary(tenantid: number): Promise<FiestaLocationSummary[]> {
return toRows<Row>(await fiestaGet('orders/getlocationsummary', { tenantid })).map((r) => ({
locationid: num(r.locationid),
locationname: str(r.locationname),
total: num(r.total),
created: num(r.created),
pending: num(r.pending),
processing: num(r.processing),
delivered: num(r.delivered),
cancelled: num(r.cancelled),
}));
}
/** /orders/getorderinsight?tenantid= — per-location monthly order counts. */
export async function getOrderInsight(tenantid: number): Promise<Row[]> {
return toRows(await fiestaGet('orders/getorderinsight', { tenantid }));
}
/** /orders/getorders?tenantid=&status=&fromdate=&todate=&pageno=&pagesize= — orders board. */
export async function getOrders(opts: {
tenantid: number;
status: string;
fromdate: string;
todate: string;
pageno?: number;
pagesize?: number;
}): Promise<Row[]> {
return toRows(
await fiestaGet('orders/getorders', {
tenantid: opts.tenantid,
status: opts.status,
fromdate: opts.fromdate,
todate: opts.todate,
pageno: opts.pageno ?? 1,
pagesize: opts.pagesize ?? 20,
}),
);
}
// ════════════════════════════════════════════════════════════════════════════
// DELIVERIES
// ════════════════════════════════════════════════════════════════════════════
export interface FiestaDeliverySummary {
total: number;
created: number;
pending: number;
accepted: number;
arrived: number;
picked: number;
active: number;
delivered: number;
cancelled: number;
}
/** /deliveries/deliverysummary?tenantid=&applocationid=&fromdate=&todate= — dispatch counts. */
export async function getDeliverySummary(opts: {
tenantid: number;
applocationid?: number;
fromdate: string;
todate: string;
}): Promise<FiestaDeliverySummary | null> {
const row = firstRow<Row>(
await fiestaGet('deliveries/deliverysummary', {
tenantid: opts.tenantid,
applocationid: opts.applocationid ?? FIESTA_APPLOCATION_ID,
fromdate: opts.fromdate,
todate: opts.todate,
}),
);
if (!row) return null;
return {
total: num(row.total),
created: num(row.created),
pending: num(row.pending),
accepted: num(row.accepted),
arrived: num(row.arrived),
picked: num(row.picked),
active: num(row.active),
delivered: num(row.delivered),
cancelled: num(row.cancelled),
};
}
/** /deliveries/getdeliveries?tenantid=&fromdate=&todate= — the master deliveries board. */
export async function getDeliveries(opts: {
tenantid: number;
fromdate: string;
todate: string;
}): Promise<Row[]> {
return toRows(
await fiestaGet('deliveries/getdeliveries', {
tenantid: opts.tenantid,
fromdate: opts.fromdate,
todate: opts.todate,
}),
);
}
/** /deliveries/getdeliveryinsight?tenantid= — daily delivery insight. */
export async function getDeliveryInsight(tenantid: number): Promise<Row[]> {
return toRows(await fiestaGet('deliveries/getdeliveryinsight', { tenantid }));
}
// ════════════════════════════════════════════════════════════════════════════
// PARTNERS / RIDERS
// ════════════════════════════════════════════════════════════════════════════
/** /partners/getriders?applocationid=&tenantid= — active rider fleet. */
export async function getRiders(opts: {
applocationid?: number;
tenantid: number;
}): Promise<Row[]> {
return toRows(
await fiestaGet('partners/getriders', {
applocationid: opts.applocationid ?? FIESTA_APPLOCATION_ID,
tenantid: opts.tenantid,
}),
);
}
/** /partners/getridershifts?applocationid= — rider shift records. */
export async function getRiderShifts(applocationid: number = FIESTA_APPLOCATION_ID): Promise<Row[]> {
return toRows(await fiestaGet('partners/getridershifts', { applocationid }));
}
// ════════════════════════════════════════════════════════════════════════════
// TENANTS / CUSTOMERS
// ════════════════════════════════════════════════════════════════════════════
/** /tenants/gettenantlocations?tenantid= — outlet locations for a tenant. */
export async function getTenantLocations(tenantid: number): Promise<Row[]> {
return toRows(await fiestaGet('tenants/gettenantlocations', { tenantid }));
}
/** /tenants/getalltenants?applocationid=&status=&pageno=&pagesize= — active tenants. */
export async function getAllTenants(opts: {
applocationid?: number;
status?: string;
pageno?: number;
pagesize?: number;
} = {}): Promise<Row[]> {
return toRows(
await fiestaGet('tenants/getalltenants', {
applocationid: opts.applocationid ?? FIESTA_APPLOCATION_ID,
status: opts.status ?? 'Active',
pageno: opts.pageno ?? 1,
pagesize: opts.pagesize ?? 20,
}),
);
}
/** /customers/gettenantcustomers?tenantid=&locationid=&pageno=&pagesize=&keyword= */
export async function getTenantCustomers(opts: {
tenantid: number;
locationid: number;
keyword?: string;
pageno?: number;
pagesize?: number;
}): Promise<Row[]> {
return toRows(
await fiestaGet('customers/gettenantcustomers', {
tenantid: opts.tenantid,
locationid: opts.locationid,
keyword: opts.keyword ?? '',
pageno: opts.pageno ?? 1,
pagesize: opts.pagesize ?? 20,
}),
);
}
// ════════════════════════════════════════════════════════════════════════════
// PRODUCTS / STOCK
// ════════════════════════════════════════════════════════════════════════════
/** /products/getstockstatement?tenantid=&locationid=&subcategoryid=&keyword=&pageno=&pagesize= */
export async function getStockStatement(opts: {
tenantid: number;
locationid: number;
subcategoryid?: number;
keyword?: string;
pageno?: number;
pagesize?: number;
}): Promise<Row[]> {
return toRows(
await fiestaGet('products/getstockstatement', {
tenantid: opts.tenantid,
locationid: opts.locationid,
subcategoryid: opts.subcategoryid,
keyword: opts.keyword ?? '',
pageno: opts.pageno ?? 1,
pagesize: opts.pagesize ?? 50,
}),
);
}
/** /products/getproductscount?tenantid=&categoryid=&subcategoryid=&approve= */
export async function getProductsCount(opts: {
tenantid: number;
categoryid: number;
subcategoryid?: number;
}): Promise<Row | null> {
return firstRow(
await fiestaGet('products/getproductscount', {
tenantid: opts.tenantid,
categoryid: opts.categoryid,
subcategoryid: opts.subcategoryid,
approve: 1,
}),
);
}
// ════════════════════════════════════════════════════════════════════════════
// USERS
// ════════════════════════════════════════════════════════════════════════════
/** Best-effort role label from the numeric roleid (roles aren't fully resolvable for every config). */
export function roleName(roleid: number): string {
const map: Record<number, string> = {
0: 'Unassigned',
1: 'Owner',
2: 'Manager',
3: 'Admin',
4: 'Staff',
5: 'Rider',
6: 'Cashier',
};
return map[roleid] || `Role ${roleid}`;
}
/** /users/getallusers?roleid=&tenantid=&pageno=&pagesize=&keyword= — staff/users under a tenant. */
export async function getAllUsers(opts: {
tenantid: number;
roleid?: number;
keyword?: string;
pageno?: number;
pagesize?: number;
}): Promise<Row[]> {
return toRows(
await fiestaGet('users/getallusers', {
tenantid: opts.tenantid,
roleid: opts.roleid,
keyword: opts.keyword ?? '',
pageno: opts.pageno ?? 1,
pagesize: opts.pagesize ?? 50,
}),
);
}
/** /users/getusers?userid= — a single user profile. */
export async function getUserById(userid: number): Promise<Row | null> {
return firstRow(await fiestaGet('users/getusers', { userid }));
}
export interface CreateUserInput {
firstname: string;
lastname?: string;
email: string;
contactno: string;
password: string;
roleid: number;
dialcode?: string;
pin?: number;
address?: string;
suburb?: string;
city?: string;
state?: string;
postcode?: string;
tenantid: number;
locationid?: number;
applocationid?: number;
status?: string;
}
/** POST /users/create — register a new web staff user. */
export async function createUser(input: CreateUserInput): Promise<Row> {
return fiestaSend<Row>('users/create', 'POST', {
authname: input.email,
firstname: input.firstname,
lastname: input.lastname ?? '',
password: input.password,
email: input.email,
dialcode: input.dialcode ?? '+91',
contactno: input.contactno,
roleid: input.roleid,
pin: input.pin ?? 0,
address: input.address ?? '',
suburb: input.suburb ?? '',
city: input.city ?? '',
state: input.state ?? '',
postcode: input.postcode ?? '',
tenantid: input.tenantid,
locationid: input.locationid ?? 0,
applocationid: input.applocationid ?? FIESTA_APPLOCATION_ID,
status: input.status ?? 'active',
});
}
export interface UpdateUserInput {
userid: number;
firstname?: string;
lastname?: string;
email?: string;
contactno?: string;
address?: string;
suburb?: string;
city?: string;
state?: string;
postcode?: string;
status?: string;
}
/** PUT /users/update — update an existing web staff user. */
export async function updateUser(input: UpdateUserInput): Promise<Row> {
return fiestaSend<Row>('users/update', 'PUT', input);
}

View File

@@ -0,0 +1,124 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Adapters that turn raw Fiesta REST rows into the display shapes the existing
* views already render (ProductMatrixItem, InventoryItem, CustomerOrder). Keeping
* the mapping here lets several views (Inventory, Operations, Reports, Orders)
* share one source of truth and keeps the components focused on presentation.
*/
import { ProductMatrixItem, InventoryItem, CustomerOrder } from '../types';
import { num, str, Row } from './fiestaApi';
/** Best-effort category label from the numeric category id Fiesta returns. */
export function categoryName(categoryid: number): string {
const map: Record<number, string> = {
1: 'Food & Dining',
2: 'Grocery & Daily',
3: 'Pharmacy',
4: 'Retail',
};
return map[categoryid] || `Category ${categoryid}`;
}
const PLACEHOLDER_IMG =
'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200';
/** Derive a healthy/low/critical status from a closing balance. */
function stockStatus(closing: number): InventoryItem['status'] {
if (closing <= 0) return 'Critical';
if (closing < 25) return 'Critical';
if (closing < 120) return 'Low Stock';
return 'Optimal';
}
/** stock-statement row -> catalog card. */
export function stockRowToProduct(row: Row): ProductMatrixItem {
const closing = num(row.closing);
const cost = num(row.productcost);
const retail = num(row.retailprice) || cost;
const sold = Math.max(0, num(row.debit));
const status = stockStatus(closing);
return {
id: str(row.productid) || str(row.productname),
name: str(row.productname) || 'Unnamed product',
sku: `SKU-${str(row.productid)}`,
unitsSold: sold,
revenue: Math.round(sold * retail),
stockStatus: status === 'Optimal' ? 'Healthy' : status,
trend: num(row.credit) > num(row.debit) ? 'up' : num(row.debit) > 0 ? 'down' : 'flat',
image: str(row.productimage) || PLACEHOLDER_IMG,
category: categoryName(num(row.categoryid)),
exposure: `${str(row.productunit) || 'unit'} · ${str(row.unitvalue) || '1'}`,
verified: num(row.retailprice) > 0,
};
}
/** stock-statement row -> hub balance ledger entry. */
export function stockRowToInventory(row: Row, locationName: string): InventoryItem {
const closing = num(row.closing);
const opening = num(row.opening);
return {
sku: `SKU-${str(row.productid)}`,
name: str(row.productname) || 'Unnamed product',
warehouse: locationName || `Location ${str(row.locationid)}`,
stockLevel: closing,
maxCapacity: Math.max(opening, closing, 100),
status: stockStatus(closing),
region: categoryName(num(row.categoryid)),
};
}
const ORDER_STATUS_MAP: Record<string, CustomerOrder['status']> = {
delivered: 'DELIVERED',
picked: 'OUT_FOR_DELIVERY',
active: 'OUT_FOR_DELIVERY',
arrived: 'OUT_FOR_DELIVERY',
accepted: 'CONFIRMED',
assigned: 'CONFIRMED',
ready: 'CONFIRMED',
created: 'PROCESSING',
pending: 'PROCESSING',
processing: 'PROCESSING',
};
/** Map a Fiesta delivery/order status string onto the view's status union. */
export function mapOrderStatus(raw: string): CustomerOrder['status'] {
return ORDER_STATUS_MAP[str(raw).toLowerCase()] || 'PROCESSING';
}
/** Format a Fiesta timestamp (ISO or "YYYY-MM-DD HH:mm:ss") into a short time label. */
export function shortTime(raw: unknown): string {
const s = str(raw);
if (!s) return '—';
const m = s.match(/(\d{1,2}):(\d{2})/);
if (m) return `${m[1]}:${m[2]}`;
return s.slice(0, 16).replace('T', ' ');
}
/** deliveries-board row -> dispatch order card. */
export function deliveryRowToOrder(row: Row): CustomerOrder {
const amount = num(row.deliveryamt) || num(row.orderamount);
const rider = str(row.ridername) || str(row.deliveryusername) || str(row.username);
return {
id: str(row.orderid) || `DLV-${str(row.deliveryid)}`,
customerName: str(row.deliverycustomer) || str(row.customername) || 'Customer',
phone: str(row.deliverycontactno) || str(row.contactno) || '—',
address:
str(row.deliveryaddress) ||
str(row.Pickupaddress) ||
str(row.pickupaddress) ||
'Address unavailable',
items: [],
amount,
time: shortTime(row.assigntime || row.deliverydate),
status: mapOrderStatus(str(row.orderstatus)),
assignedRider: rider || 'Pending Assignment',
hub: str(row.pickupcustomer) || str(row.pickuplocation) || `Location ${str(row.locationid)}`,
itemCount: num(row.itemcount),
locationid: num(row.locationid)
};
}

View File

@@ -0,0 +1,248 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
/**
* TanStack Query hooks wrapping the Fiesta REST client in `./fiestaApi`.
*
* Components call these (never fetch directly) to get caching, dedup, and
* loading/error state. These power the operational pages (Inventory, Orders &
* Deliveries, Operations, Reports, Stores/Logistics/Staffing); the Dashboard
* continues to use the Hasura hooks in `./queries`.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
FIESTA_TENANT_ID,
FIESTA_APPLOCATION_ID,
FIESTA_PRIMARY_LOCATION_ID,
getOrderSummary,
getLocationSummary,
getOrderInsight,
getOrders,
getDeliverySummary,
getDeliveries,
getDeliveryInsight,
getRiders,
getRiderShifts,
getTenantLocations,
getAllTenants,
getTenantCustomers,
getStockStatement,
getProductsCount,
getAllUsers,
getUserById,
createUser,
updateUser,
CreateUserInput,
} from './fiestaApi';
export const fiestaKeys = {
orderSummary: (tenantid: number, fromdate: string, todate: string) =>
['fiesta', 'orderSummary', tenantid, fromdate, todate] as const,
locationSummary: (tenantid: number) => ['fiesta', 'locationSummary', tenantid] as const,
orderInsight: (tenantid: number) => ['fiesta', 'orderInsight', tenantid] as const,
orders: (params: Record<string, unknown>) => ['fiesta', 'orders', params] as const,
deliverySummary: (params: Record<string, unknown>) => ['fiesta', 'deliverySummary', params] as const,
deliveries: (params: Record<string, unknown>) => ['fiesta', 'deliveries', params] as const,
deliveryInsight: (tenantid: number) => ['fiesta', 'deliveryInsight', tenantid] as const,
riders: (params: Record<string, unknown>) => ['fiesta', 'riders', params] as const,
riderShifts: (applocationid: number) => ['fiesta', 'riderShifts', applocationid] as const,
tenantLocations: (tenantid: number) => ['fiesta', 'tenantLocations', tenantid] as const,
allTenants: (params: Record<string, unknown>) => ['fiesta', 'allTenants', params] as const,
tenantCustomers: (params: Record<string, unknown>) => ['fiesta', 'tenantCustomers', params] as const,
stockStatement: (params: Record<string, unknown>) => ['fiesta', 'stockStatement', params] as const,
productsCount: (params: Record<string, unknown>) => ['fiesta', 'productsCount', params] as const,
users: (params: Record<string, unknown>) => ['fiesta', 'users', params] as const,
user: (userid: number) => ['fiesta', 'user', userid] as const,
};
// ── Orders ──────────────────────────────────────────────────────────────────
export function useFiestaOrderSummary(tenantid: number = FIESTA_TENANT_ID, fromdate: string, todate: string) {
return useQuery({
queryKey: fiestaKeys.orderSummary(tenantid, fromdate, todate),
queryFn: () => getOrderSummary(tenantid, fromdate, todate),
enabled: Boolean(tenantid && fromdate && todate),
});
}
export function useFiestaLocationSummary(tenantid: number = FIESTA_TENANT_ID) {
return useQuery({
queryKey: fiestaKeys.locationSummary(tenantid),
queryFn: () => getLocationSummary(tenantid),
enabled: Boolean(tenantid),
});
}
export function useFiestaOrderInsight(tenantid: number = FIESTA_TENANT_ID) {
return useQuery({
queryKey: fiestaKeys.orderInsight(tenantid),
queryFn: () => getOrderInsight(tenantid),
enabled: Boolean(tenantid),
});
}
export function useFiestaOrders(opts: {
tenantid: number;
status: string;
fromdate: string;
todate: string;
pageno?: number;
pagesize?: number;
}) {
return useQuery({
queryKey: fiestaKeys.orders(opts),
queryFn: () => getOrders(opts),
enabled: Boolean(opts.tenantid && opts.status && opts.fromdate && opts.todate),
});
}
// ── Deliveries ────────────────────────────────────────────────────────────────
export function useFiestaDeliverySummary(opts: {
tenantid: number;
applocationid?: number;
fromdate: string;
todate: string;
}) {
return useQuery({
queryKey: fiestaKeys.deliverySummary(opts),
queryFn: () => getDeliverySummary(opts),
enabled: Boolean(opts.tenantid && opts.fromdate && opts.todate),
});
}
export function useFiestaDeliveries(opts: { tenantid: number; fromdate: string; todate: string }) {
return useQuery({
queryKey: fiestaKeys.deliveries(opts),
queryFn: () => getDeliveries(opts),
enabled: Boolean(opts.tenantid && opts.fromdate && opts.todate),
});
}
export function useFiestaDeliveryInsight(tenantid: number = FIESTA_TENANT_ID) {
return useQuery({
queryKey: fiestaKeys.deliveryInsight(tenantid),
queryFn: () => getDeliveryInsight(tenantid),
enabled: Boolean(tenantid),
});
}
// ── Partners / Riders ─────────────────────────────────────────────────────────
export function useFiestaRiders(opts: { applocationid?: number; tenantid: number }) {
return useQuery({
queryKey: fiestaKeys.riders(opts),
queryFn: () => getRiders(opts),
enabled: Boolean(opts.tenantid),
});
}
export function useFiestaRiderShifts(applocationid: number = FIESTA_APPLOCATION_ID) {
return useQuery({
queryKey: fiestaKeys.riderShifts(applocationid),
queryFn: () => getRiderShifts(applocationid),
enabled: Boolean(applocationid),
});
}
// ── Tenants / Customers ─────────────────────────────────────────────────────────
export function useFiestaTenantLocations(tenantid: number = FIESTA_TENANT_ID) {
return useQuery({
queryKey: fiestaKeys.tenantLocations(tenantid),
queryFn: () => getTenantLocations(tenantid),
enabled: Boolean(tenantid),
});
}
export function useFiestaAllTenants(opts: {
applocationid?: number;
status?: string;
pageno?: number;
pagesize?: number;
} = {}) {
return useQuery({
queryKey: fiestaKeys.allTenants(opts),
queryFn: () => getAllTenants(opts),
});
}
export function useFiestaTenantCustomers(opts: {
tenantid: number;
locationid: number;
keyword?: string;
pageno?: number;
pagesize?: number;
}) {
return useQuery({
queryKey: fiestaKeys.tenantCustomers(opts),
queryFn: () => getTenantCustomers(opts),
enabled: Boolean(opts.tenantid && opts.locationid),
});
}
// ── Products / Stock ─────────────────────────────────────────────────────────
export function useFiestaStockStatement(opts: {
tenantid: number;
locationid: number;
subcategoryid?: number;
keyword?: string;
pageno?: number;
pagesize?: number;
}) {
return useQuery({
queryKey: fiestaKeys.stockStatement(opts),
queryFn: () => getStockStatement(opts),
enabled: Boolean(opts.tenantid && opts.locationid),
});
}
export function useFiestaProductsCount(opts: { tenantid: number; categoryid: number; subcategoryid?: number }) {
return useQuery({
queryKey: fiestaKeys.productsCount(opts),
queryFn: () => getProductsCount(opts),
enabled: Boolean(opts.tenantid && opts.categoryid),
});
}
// ── Users ─────────────────────────────────────────────────────────────────────
export function useFiestaUsers(opts: {
tenantid: number;
roleid?: number;
keyword?: string;
pageno?: number;
pagesize?: number;
}) {
return useQuery({
queryKey: fiestaKeys.users(opts),
queryFn: () => getAllUsers(opts),
enabled: Boolean(opts.tenantid),
});
}
export function useFiestaUser(userid: number) {
return useQuery({
queryKey: fiestaKeys.user(userid),
queryFn: () => getUserById(userid),
enabled: Boolean(userid),
});
}
/** Create a user, then refresh every users list on success. */
export function useFiestaCreateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: CreateUserInput) => createUser(input),
onSuccess: () => qc.invalidateQueries({ queryKey: ['fiesta', 'users'] }),
});
}
/** Update a user, then refresh every users list on success. */
export function useFiestaUpdateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: Parameters<typeof updateUser>[0]) => updateUser(input),
onSuccess: () => qc.invalidateQueries({ queryKey: ['fiesta', 'users'] }),
});
}
export { FIESTA_TENANT_ID, FIESTA_APPLOCATION_ID, FIESTA_PRIMARY_LOCATION_ID };

283
src/services/queries.ts Normal file
View File

@@ -0,0 +1,283 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
/**
* TanStack Query hooks that wrap the raw API functions in `./api`.
*
* Components call these hooks (never `fetch`/the api functions directly) so they
* get caching, dedup, loading/error state, and refetching for free. Covers the
* non-mobile `/api/rest/*` catalog from developer.nearledaily.com.
*/
import { useQuery } from '@tanstack/react-query';
import {
DEFAULT_TENANT_ID,
DEFAULT_CONFIG_ID,
getDashboardKpis,
// Users
getUsers,
getAppRoles,
// Orders
getOrders,
getOrderSummary,
// Tenants
getTenantInfo,
getTenantLocations,
getCustomersByTenant,
getTenantCustomers,
getTenantDeliveries,
// Products
getProductCategories,
getSubcategory,
getProductSubcategories,
getProductVariants,
getStockStatement,
getProductsCount,
// Apps & Locations
getAppLocations,
getAppLocationConfig,
getAppTypes,
// Partners
getPartners,
getRiderShifts,
// Invoice
getInvoiceInsight,
} from './api';
/** Centralized, stable query keys — keep all cache keys discoverable in one place. */
export const queryKeys = {
dashboardKpis: (tenantid: number) => ['dashboardKpis', tenantid] as const,
// Users
users: (params: Record<string, unknown>) => ['users', params] as const,
appRoles: (configid: number) => ['appRoles', configid] as const,
// Orders
orders: (params: Record<string, unknown>) => ['orders', params] as const,
orderSummary: (tenantid: number, fromdate: string, todate: string, configid: number) =>
['orderSummary', tenantid, fromdate, todate, configid] as const,
// Tenants
tenantInfo: (tenantid: number) => ['tenantInfo', tenantid] as const,
tenantLocations: (tenantid: number) => ['tenantLocations', tenantid] as const,
customersByTenant: (params: Record<string, unknown>) => ['customersByTenant', params] as const,
tenantCustomers: (params: Record<string, unknown>) => ['tenantCustomers', params] as const,
tenantDeliveries: (params: Record<string, unknown>) => ['tenantDeliveries', params] as const,
// Products
productCategories: (moduleid: number) => ['productCategories', moduleid] as const,
subcategory: (moduleid: number, categoryid: number) => ['subcategory', moduleid, categoryid] as const,
productSubcategories: (categoryid: number) => ['productSubcategories', categoryid] as const,
productVariants: (tenantid: number, subcategoryid: number) =>
['productVariants', tenantid, subcategoryid] as const,
stockStatement: (params: Record<string, unknown>) => ['stockStatement', params] as const,
productsCount: (params: Record<string, unknown>) => ['productsCount', params] as const,
// Apps & Locations
appLocations: () => ['appLocations'] as const,
appLocationConfig: () => ['appLocationConfig'] as const,
appTypes: (tag: string) => ['appTypes', tag] as const,
// Partners
partners: (params: Record<string, unknown>) => ['partners', params] as const,
riderShifts: (applocationid: number) => ['riderShifts', applocationid] as const,
// Invoice
invoiceInsight: (tenantid: number) => ['invoiceInsight', tenantid] as const,
};
// ── Dashboard ─────────────────────────────────────────────────────────────────
export function useDashboardKpis(tenantid: number = DEFAULT_TENANT_ID) {
return useQuery({
queryKey: queryKeys.dashboardKpis(tenantid),
queryFn: () => getDashboardKpis(tenantid),
});
}
// ── Users ───────────────────────────────────────────────────────────────────
export function useUsers(opts: { tenantid: number; roleid?: number; limit?: number; offset?: number }) {
return useQuery({
queryKey: queryKeys.users(opts),
queryFn: () => getUsers(opts),
enabled: Boolean(opts.tenantid),
});
}
export function useAppRoles(configid = 15) {
return useQuery({ queryKey: queryKeys.appRoles(configid), queryFn: () => getAppRoles(configid) });
}
// ── Orders ────────────────────────────────────────────────────────────────────
export function useOrders(opts: {
start: string;
end: string;
status: string;
limit?: number;
offset?: number;
}) {
return useQuery({
queryKey: queryKeys.orders(opts),
queryFn: () => getOrders(opts),
enabled: Boolean(opts.start && opts.end && opts.status),
});
}
export function useOrderSummary(
tenantid: number,
fromdate: string,
todate: string,
configid: number = DEFAULT_CONFIG_ID,
) {
return useQuery({
queryKey: queryKeys.orderSummary(tenantid, fromdate, todate, configid),
queryFn: () => getOrderSummary(tenantid, fromdate, todate, configid),
enabled: Boolean(tenantid && fromdate && todate),
});
}
// ── Tenants ───────────────────────────────────────────────────────────────────
export function useTenantInfo(tenantid: number = DEFAULT_TENANT_ID) {
return useQuery({ queryKey: queryKeys.tenantInfo(tenantid), queryFn: () => getTenantInfo(tenantid) });
}
export function useTenantLocations(tenantid: number = DEFAULT_TENANT_ID) {
return useQuery({
queryKey: queryKeys.tenantLocations(tenantid),
queryFn: () => getTenantLocations(tenantid),
});
}
export function useCustomersByTenant(opts: { tenantid: number; limit?: number; offset?: number }) {
return useQuery({
queryKey: queryKeys.customersByTenant(opts),
queryFn: () => getCustomersByTenant(opts),
enabled: Boolean(opts.tenantid),
});
}
export function useTenantCustomers(opts: {
tenantid: number;
locationid: number;
limit?: number;
offset?: number;
}) {
return useQuery({
queryKey: queryKeys.tenantCustomers(opts),
queryFn: () => getTenantCustomers(opts),
enabled: Boolean(opts.tenantid && opts.locationid),
});
}
export function useTenantDeliveries(opts: {
tenantid: number;
status?: string;
fromdate: string;
todate: string;
keyword?: string;
limit?: number;
offset?: number;
}) {
return useQuery({
queryKey: queryKeys.tenantDeliveries(opts),
queryFn: () => getTenantDeliveries(opts),
enabled: Boolean(opts.tenantid && opts.fromdate && opts.todate),
});
}
// ── Products ──────────────────────────────────────────────────────────────────
export function useProductCategories(moduleid: number) {
return useQuery({
queryKey: queryKeys.productCategories(moduleid),
queryFn: () => getProductCategories(moduleid),
enabled: Boolean(moduleid),
});
}
export function useSubcategory(moduleid: number, categoryid: number) {
return useQuery({
queryKey: queryKeys.subcategory(moduleid, categoryid),
queryFn: () => getSubcategory(moduleid, categoryid),
enabled: Boolean(moduleid && categoryid),
});
}
export function useProductSubcategories(categoryid: number) {
return useQuery({
queryKey: queryKeys.productSubcategories(categoryid),
queryFn: () => getProductSubcategories(categoryid),
enabled: Boolean(categoryid),
});
}
export function useProductVariants(tenantid: number, subcategoryid: number) {
return useQuery({
queryKey: queryKeys.productVariants(tenantid, subcategoryid),
queryFn: () => getProductVariants(tenantid, subcategoryid),
enabled: Boolean(tenantid && subcategoryid),
});
}
export function useStockStatement(opts: {
tenantid: number;
locationid: number;
subcategoryid?: number;
keyword?: string;
limit?: number;
offset?: number;
}) {
return useQuery({
queryKey: queryKeys.stockStatement(opts),
queryFn: () => getStockStatement(opts),
enabled: Boolean(opts.tenantid && opts.locationid),
});
}
export function useProductsCount(opts: { tenantid: number; categoryid: number; subcategoryid?: number }) {
return useQuery({
queryKey: queryKeys.productsCount(opts),
queryFn: () => getProductsCount(opts),
enabled: Boolean(opts.tenantid && opts.categoryid),
});
}
// ── Apps & Locations ──────────────────────────────────────────────────────────
export function useAppLocations() {
return useQuery({ queryKey: queryKeys.appLocations(), queryFn: () => getAppLocations() });
}
export function useAppLocationConfig() {
return useQuery({ queryKey: queryKeys.appLocationConfig(), queryFn: () => getAppLocationConfig() });
}
export function useAppTypes(tag: string) {
return useQuery({
queryKey: queryKeys.appTypes(tag),
queryFn: () => getAppTypes(tag),
enabled: Boolean(tag),
});
}
// ── Partners ──────────────────────────────────────────────────────────────────
export function usePartners(opts: {
applocationid: number;
partnerid?: number;
limit?: number;
offset?: number;
}) {
return useQuery({
queryKey: queryKeys.partners(opts),
queryFn: () => getPartners(opts),
enabled: Boolean(opts.applocationid),
});
}
export function useRiderShifts(applocationid: number) {
return useQuery({
queryKey: queryKeys.riderShifts(applocationid),
queryFn: () => getRiderShifts(applocationid),
enabled: Boolean(applocationid),
});
}
// ── Invoice ───────────────────────────────────────────────────────────────────
export function useInvoiceInsight(tenantid: number = DEFAULT_TENANT_ID) {
return useQuery({
queryKey: queryKeys.invoiceInsight(tenantid),
queryFn: () => getInvoiceInsight(tenantid),
});
}

111
src/types.ts Normal file
View File

@@ -0,0 +1,111 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
export type MainSection = 'dashboard' | 'stores' | 'inventory' | 'orders' | 'users' | 'settings' | 'reports' | 'operations';
export interface KPICardData {
title: string;
value: string;
subtext: string;
trend?: string;
isPositive?: boolean;
sparkline: number[];
badge?: string;
}
export interface RevenueHeatmapDay {
day: string;
hours: {
'08:00': number;
'10:00': number;
'12:00': number;
'14:00': number;
'16:00': number;
'18:00': number;
'20:00': number;
};
}
export interface LeaderboardNode {
rank: string;
name: string;
percentage: number;
revenue: string;
}
export interface ProductMatrixItem {
id: string;
name: string;
sku: string;
unitsSold: number;
revenue: number;
stockStatus: 'Healthy' | 'Low Stock' | 'Critical';
trend: 'up' | 'flat' | 'down';
image: string;
category: string;
exposure: string;
verified: boolean;
}
export interface InventoryItem {
sku: string;
name: string;
warehouse: string;
stockLevel: number;
maxCapacity: number;
status: 'Optimal' | 'Low Stock' | 'Critical';
// Region/zone label. Live data supplies the outlet name; the static seed used
// CBE-* zone codes, so this stays a free string.
region: string;
}
export interface OrderItem {
id: string;
store: string;
amount: number;
time: string;
status: 'SHIPPED' | 'FLAGGED' | 'PROCESSING';
}
export interface ImportLog {
timestamp: string;
batchRef: string;
type: string;
source: string;
result: string;
status: 'SUCCESS' | 'FAILED';
}
export interface OperationalAlert {
id: string;
type: 'critical' | 'warning' | 'info';
title: string;
details: string;
time: string;
}
export interface CustomerOrder {
id: string;
customerName: string;
phone: string;
address: string;
items: Array<{ name: string; quantity: number | string; price: number }>;
amount: number;
time: string;
status: 'DELIVERED' | 'OUT_FOR_DELIVERY' | 'CONFIRMED' | 'PROCESSING';
assignedRider: string;
hub: string;
// Live deliveries return a line-item count without the lines themselves;
// when present, views show this instead of items.length.
itemCount?: number;
locationid?: number;
}
export interface RegionalPerformance {
region: string;
sales: string;
revenueValue: number;
maxTarget: number;
}

16
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,16 @@
/// <reference types="vite/client" />
declare module "*.png" {
const value: string;
export default value;
}
declare module "*.jpg" {
const value: string;
export default value;
}
declare module "*.svg" {
const value: string;
export default value;
}

26
tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

55
vite.config.ts Normal file
View File

@@ -0,0 +1,55 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import {defineConfig, loadEnv} from 'vite';
export default defineConfig(({mode}) => {
// Load all env vars (including non-VITE_ prefixed ones) so the Hasura secret
// stays server-side in the dev proxy and never reaches the client bundle.
const env = loadEnv(mode, process.cwd(), '');
const hasuraSecret = env.HASURA_ADMIN_SECRET || '';
return {
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
server: {
// HMR is disabled in AI Studio via DISABLE_HMR env var.
// Do not modify—file watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true',
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
watch: process.env.DISABLE_HMR === 'true' ? null : {},
// Proxy Hasura REST calls so the admin secret is injected server-side
// (kept out of the browser bundle) and CORS is avoided. The app calls e.g.
// /hasura/getordersummary?... -> https://api.workolik.com/api/rest/getordersummary?...
proxy: {
'/hasura': {
target: 'https://api.workolik.com',
changeOrigin: true,
secure: true,
rewrite: (p) => p.replace(/^\/hasura/, '/api/rest'),
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq) => {
if (hasuraSecret) {
proxyReq.setHeader('x-hasura-admin-secret', hasuraSecret);
}
});
},
},
// Proxy the Fiesta REST API (fiesta.nearle.app). It is CORS-enabled, but
// routing through the dev server keeps the network tab consistent with
// /hasura and sidesteps any preflight surprises. The app calls e.g.
// /fiesta/live/api/v1/web/orders/getordersummary?... -> https://fiesta.nearle.app/live/api/v1/web/...
'/fiesta': {
target: 'https://fiesta.nearle.app',
changeOrigin: true,
secure: true,
rewrite: (p) => p.replace(/^\/fiesta/, ''),
},
},
},
};
});