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