udpates on the ui changesand api integration
This commit is contained in:
40
src/components/AwaitingApi.tsx
Normal file
40
src/components/AwaitingApi.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { PlugZap } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Explicit "this data has no backend yet" placeholder. Used for UI sections whose
|
||||
* live API is not built (see docs/03_REQUIRED_BACKEND_APIS.md → [R*] ids). We show
|
||||
* this instead of fabricated numbers so nothing fake reaches staging.
|
||||
*/
|
||||
export default function AwaitingApi({
|
||||
label,
|
||||
api,
|
||||
className = '',
|
||||
compact = false,
|
||||
}: {
|
||||
/** What the section will show once wired (e.g. "Operational alerts"). */
|
||||
label: string;
|
||||
/** The required-API id from the spec doc, e.g. "[R12]". */
|
||||
api?: string;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center text-center rounded-xl border border-dashed border-slate-300 bg-slate-50/60 text-slate-400 ${
|
||||
compact ? 'p-4 gap-1.5' : 'p-8 gap-2'
|
||||
} ${className}`}
|
||||
>
|
||||
<PlugZap size={compact ? 16 : 22} className="text-slate-300" />
|
||||
<p className={`font-semibold text-slate-500 ${compact ? 'text-xs' : 'text-sm'}`}>{label}</p>
|
||||
<p className="text-[11px] leading-relaxed">
|
||||
Awaiting backend API{api ? ` ${api}` : ''} — no live data source yet.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,15 +5,15 @@
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
ShoppingBag,
|
||||
PackageCheck,
|
||||
Wallet,
|
||||
TrendingUp,
|
||||
Store,
|
||||
MapPin,
|
||||
Phone,
|
||||
Sparkles,
|
||||
AlertTriangle,
|
||||
Activity,
|
||||
Clock,
|
||||
ArrowUpRight,
|
||||
} from 'lucide-react';
|
||||
import { useOrderSummary, useTenantInfo, useTenantLocations, useInvoiceInsight } from '../services/queries';
|
||||
import { DEFAULT_TENANT_ID, DEFAULT_CONFIG_ID } from '../services/api';
|
||||
@@ -81,10 +81,42 @@ export default function DashboardView({ searchQuery }: DashboardViewProps) {
|
||||
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: regionFulfillmentPct == null ? '—' : `${regionFulfillmentPct.toFixed(1)}%`, icon: Sparkles, chip: 'bg-emerald-50 text-emerald-600', loading: summaryQ.isLoading },
|
||||
{ title: 'MONTHLY REVENUE', display: money(monthlyRevenue), 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 },
|
||||
{
|
||||
title: 'ACTIVE OUTLETS',
|
||||
display: `${activeStoresCount} / ${totalStoresCount}`,
|
||||
sub: `${activePct}% of the network is live`,
|
||||
icon: Store,
|
||||
bar: 'from-purple-500 to-indigo-500',
|
||||
chip: 'bg-purple-50 text-purple-650 ring-purple-100',
|
||||
loading: locationsQ.isLoading,
|
||||
},
|
||||
{
|
||||
title: 'REGION FULFILLMENT',
|
||||
display: regionFulfillmentPct == null ? '—' : `${regionFulfillmentPct.toFixed(1)}%`,
|
||||
sub: `${ordersDelivered.toLocaleString('en-IN')} of ${ordersTotal.toLocaleString('en-IN')} orders delivered`,
|
||||
icon: Activity,
|
||||
bar: 'from-indigo-500 to-sky-500',
|
||||
chip: 'bg-indigo-50 text-indigo-600 ring-indigo-100',
|
||||
loading: summaryQ.isLoading,
|
||||
},
|
||||
{
|
||||
title: 'MONTHLY REVENUE',
|
||||
display: money(monthlyRevenue),
|
||||
sub: 'Gross billed · month-to-date',
|
||||
icon: Wallet,
|
||||
bar: 'from-sky-500 to-cyan-500',
|
||||
chip: 'bg-sky-50 text-sky-600 ring-sky-100',
|
||||
loading: insightQ.isLoading,
|
||||
},
|
||||
{
|
||||
title: 'MONTHLY PROFIT',
|
||||
display: money(monthlyProfit),
|
||||
sub: 'Net margin · month-to-date',
|
||||
icon: TrendingUp,
|
||||
bar: 'from-emerald-500 to-teal-500',
|
||||
chip: 'bg-emerald-50 text-emerald-600 ring-emerald-100',
|
||||
loading: insightQ.isLoading,
|
||||
},
|
||||
];
|
||||
|
||||
const statusRows = [
|
||||
@@ -97,35 +129,57 @@ export default function DashboardView({ searchQuery }: DashboardViewProps) {
|
||||
|
||||
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>
|
||||
{/* ── Immersive Executive Banner (cover image + slate→purple gradient overlay) ── */}
|
||||
<div className="relative rounded-2xl p-6 md:p-8 text-white shadow-xl border border-purple-500/20 overflow-hidden animate-in fade-in duration-300">
|
||||
{/* Cover image background & decorative glow */}
|
||||
<div className="absolute inset-0 z-0 overflow-hidden rounded-2xl">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1551288049-bebda4e38f71?auto=format&fit=crop&w=1400&q=80"
|
||||
alt="Executive operations dashboard"
|
||||
className="w-full h-full object-cover object-center opacity-40"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-slate-950 via-slate-900/90 to-purple-950/80" />
|
||||
<div className="absolute top-0 right-0 w-72 h-72 bg-purple-500/10 rounded-full blur-3xl -mr-20 -mt-20 pointer-events-none" />
|
||||
<div className="absolute bottom-0 left-0 w-56 h-56 bg-indigo-500/10 rounded-full blur-2xl -ml-20 -mb-20 pointer-events-none" />
|
||||
</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…
|
||||
{/* Content row */}
|
||||
<div className="relative z-10 flex flex-col md:flex-row md:items-center justify-between gap-lg">
|
||||
<div>
|
||||
<h1 className="font-sans font-bold text-2xl md:text-3xl tracking-tight text-white flex items-center gap-2.5">
|
||||
Executive Command Center
|
||||
<span className="text-[10px] text-purple-200 font-bold bg-purple-900/60 border border-purple-500/30 px-2 py-0.5 rounded-full uppercase tracking-wider animate-pulse">
|
||||
Live Core
|
||||
</span>
|
||||
) : 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>
|
||||
)}
|
||||
</h1>
|
||||
<p className="text-slate-300 font-sans text-sm mt-2 leading-relaxed whitespace-nowrap">
|
||||
Month-to-date order operations for <strong className="text-white font-semibold">{tenantName}</strong>, pulled live from the API.
|
||||
</p>
|
||||
|
||||
<div className="mt-4">
|
||||
{loading ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-[11px] font-bold text-slate-300 uppercase tracking-wide">
|
||||
<span className="w-2 h-2 rounded-full bg-slate-400 animate-pulse" /> Syncing live data…
|
||||
</span>
|
||||
) : errored ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-[11px] font-bold text-rose-300 uppercase tracking-wide" title="Restart the dev server so the /hasura proxy is active.">
|
||||
<span className="w-2 h-2 rounded-full bg-rose-400" /> Live data unavailable
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 text-[11px] font-bold text-emerald-300 uppercase tracking-wide">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" /> Live · {tenantName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reporting scope panel */}
|
||||
<div className="flex flex-col items-start md:items-end gap-2 shrink-0">
|
||||
<div className="inline-flex items-center gap-2 bg-white/10 backdrop-blur-md border border-white/15 rounded-xl px-3.5 py-2.5 shadow-sm">
|
||||
<Clock size={14} className="text-purple-300" />
|
||||
<span className="text-xs font-bold font-mono text-white tracking-tight">{fromdate} → {todate}</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-400 uppercase tracking-widest font-bold">Month-to-date reporting scope</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,23 +198,31 @@ export default function DashboardView({ searchQuery }: DashboardViewProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* KPI cards — all live from getordersummary */}
|
||||
{/* KPI cards — all live from getordersummary / getinvoiceinsight */}
|
||||
<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)]"
|
||||
className="group relative flex flex-col overflow-hidden bg-white border border-slate-200/70 rounded-2xl p-5 shadow-[0_1px_2px_rgba(16,24,40,0.04)] transition-all duration-300 hover:-translate-y-1 hover:border-purple-200 hover:shadow-[0_16px_36px_rgba(16,24,40,0.10)]"
|
||||
>
|
||||
<div className={`h-7 w-7 rounded-lg flex items-center justify-center ${kpi.chip}`}>
|
||||
<Icon size={14} />
|
||||
{/* Gradient accent bar */}
|
||||
<span className={`absolute inset-x-0 top-0 h-1 bg-gradient-to-r ${kpi.bar}`} />
|
||||
<div className="flex items-start justify-between">
|
||||
<div className={`h-11 w-11 rounded-xl flex items-center justify-center ring-1 group-hover:scale-110 transition-transform duration-300 ${kpi.chip}`}>
|
||||
<Icon size={19} />
|
||||
</div>
|
||||
<ArrowUpRight size={16} className="text-slate-300 group-hover:text-purple-400 transition-colors" />
|
||||
</div>
|
||||
<p className="text-[10px] font-semibold text-zinc-400 tracking-wider uppercase font-sans mt-3">
|
||||
<p className="text-[10px] font-bold text-slate-400 tracking-widest uppercase font-sans mt-4">
|
||||
{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 className="font-sans font-extrabold text-[28px] leading-tight text-slate-900 tracking-tight mt-1">
|
||||
{kpi.loading ? <span className="text-slate-300">…</span> : kpi.display}
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-400 font-medium mt-1.5 leading-snug">
|
||||
{kpi.sub}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -170,115 +232,140 @@ export default function DashboardView({ searchQuery }: DashboardViewProps) {
|
||||
{/* 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 className="bg-white border border-slate-200/70 rounded-2xl p-lg flex flex-col shadow-[0_1px_3px_rgba(16,24,40,0.05)]">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className="h-9 w-9 rounded-xl bg-emerald-50 text-emerald-600 ring-1 ring-emerald-100 flex items-center justify-center shrink-0">
|
||||
<Activity size={17} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-sans font-bold text-base text-slate-900 tracking-tight">Store Outlet Status</h3>
|
||||
<p className="text-slate-500 text-xs font-sans mt-0.5">Active share of all registered nodes.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="my-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" />
|
||||
<div className="my-lg flex justify-center items-center">
|
||||
<div className="relative w-44 h-44 flex items-center justify-center">
|
||||
{/* soft glow behind the ring */}
|
||||
<div className="absolute w-32 h-32 rounded-full bg-emerald-400/10 blur-2xl" />
|
||||
<svg className="w-full h-full transform -rotate-90 relative" viewBox="0 0 100 100">
|
||||
<circle cx="50" cy="50" r="40" fill="transparent" stroke="#eef2f6" strokeWidth="9" />
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="40"
|
||||
fill="transparent"
|
||||
stroke="#10b981"
|
||||
strokeWidth="8"
|
||||
stroke="url(#dashGradient)"
|
||||
strokeWidth="9"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={dashOffset}
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-700"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient id="dashGradient" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stopColor="#10b981" />
|
||||
<stop offset="100%" stopColor="#0d9488" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="font-sans font-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>
|
||||
<span className="font-sans font-extrabold text-4xl text-slate-900 tracking-tight">{activePct}%</span>
|
||||
<span className="text-[10px] text-emerald-600 uppercase tracking-widest font-bold mt-1">Active</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-[#f1f5f9] text-xs">
|
||||
<div className="divide-y divide-slate-100 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">
|
||||
<div key={r.label} className="flex justify-between items-center py-2.5">
|
||||
<span className="flex items-center gap-2 text-slate-500 font-medium">
|
||||
<span className={`w-2.5 h-2.5 rounded-full ${r.dot}`} />
|
||||
{r.label}
|
||||
</span>
|
||||
<span className="font-mono font-bold text-zinc-700">{r.value.toLocaleString('en-IN')}</span>
|
||||
<span className="font-mono font-bold text-slate-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 className="flex justify-between items-center pt-2.5">
|
||||
<span className="text-slate-600 font-bold">Total Nodes</span>
|
||||
<span className="font-mono font-extrabold text-purple-650">{totalStoresCount.toLocaleString('en-IN')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Store locations (live) */}
|
||||
<div className="lg:col-span-2 bg-white border border-[#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
|
||||
<div className="lg:col-span-2 bg-white border border-slate-200/70 rounded-2xl p-lg shadow-[0_1px_3px_rgba(16,24,40,0.05)]">
|
||||
<div className="flex justify-between items-center mb-md pb-sm border-b border-slate-100">
|
||||
<h3 className="font-sans font-bold text-base text-slate-900 flex items-center gap-2.5 tracking-tight">
|
||||
<span className="h-9 w-9 rounded-xl bg-purple-50 text-purple-650 ring-1 ring-purple-100 flex items-center justify-center">
|
||||
<Store size={17} />
|
||||
</span>
|
||||
Store Locations
|
||||
</h3>
|
||||
<span className="text-[10px] text-[#581c87] uppercase font-bold bg-purple-50 px-2 py-0.5 rounded tracking-wide border border-purple-100">
|
||||
<span className="text-[10px] text-purple-650 uppercase font-bold bg-purple-50 px-2.5 py-1 rounded-lg tracking-wide border border-purple-100">
|
||||
{locationsQ.isLoading ? 'Loading…' : `${locations.length} Outlet${locations.length === 1 ? '' : 's'}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{locationsQ.isLoading ? (
|
||||
<div className="text-center py-xl text-zinc-400 text-xs">Loading store locations…</div>
|
||||
<div className="text-center py-xl text-slate-400 text-xs">Loading store locations…</div>
|
||||
) : locations.length === 0 ? (
|
||||
<div className="text-center py-xl text-zinc-400 text-xs">No store locations found for this tenant.</div>
|
||||
<div className="text-center py-xl text-slate-400 text-xs">No store locations found for this tenant.</div>
|
||||
) : (
|
||||
<div className="space-y-sm max-h-80 overflow-y-auto">
|
||||
<div className="space-y-sm max-h-80 overflow-y-auto pr-1">
|
||||
{locations.map((loc, i) => {
|
||||
const sum = summaries.find((s) => s.locationid === Number(loc.locationid));
|
||||
const deliveries = sum?.delivered ?? 0;
|
||||
const orders = Math.max(sum?.delivered ?? 0, sum?.total ?? 0);
|
||||
|
||||
const isActive = str(loc.status).toLowerCase() === 'active';
|
||||
const name = str(loc.locationname);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={str(loc.locationid) || i}
|
||||
className="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"
|
||||
className="group p-3.5 border border-slate-200/70 rounded-xl bg-slate-50/40 flex justify-between items-start gap-md hover:border-purple-200 hover:bg-white hover:shadow-[0_6px_18px_rgba(16,24,40,0.06)] transition-all animate-in fade-in"
|
||||
>
|
||||
<div className="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)}
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
{/* Outlet initial badge */}
|
||||
<div className="h-9 w-9 shrink-0 rounded-lg bg-gradient-to-br from-purple-500 to-indigo-500 text-white flex items-center justify-center font-bold text-xs uppercase shadow-sm">
|
||||
{name.slice(0, 2) || '—'}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-sans font-semibold text-sm text-slate-900 truncate">{name}</p>
|
||||
<p className="text-[11px] text-slate-500 mt-0.5 flex items-center gap-1">
|
||||
<MapPin size={11} className="shrink-0 text-slate-400" />
|
||||
<span className="truncate">{str(loc.address) || `${str(loc.suburb)}, ${str(loc.city)}`}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
{str(loc.contactno) && (
|
||||
<p className="text-[11px] text-slate-500 mt-0.5 flex items-center gap-1">
|
||||
<Phone size={11} className="shrink-0 text-slate-400" />
|
||||
{str(loc.contactno)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Node-specific Orders and Dispatches */}
|
||||
<div className="flex items-center gap-2 mt-2.5 flex-wrap">
|
||||
<span className="text-[10px] bg-purple-50 text-purple-650 font-bold px-2 py-0.5 rounded-md border border-purple-100/60">
|
||||
{orders} Orders
|
||||
</span>
|
||||
<span className="text-[10px] bg-emerald-50 text-emerald-700 font-bold px-2 py-0.5 rounded-md border border-emerald-100/60">
|
||||
{deliveries} Dispatched
|
||||
</span>
|
||||
{orders > 0 && (
|
||||
<span className="text-[10px] text-slate-400 font-semibold">
|
||||
{Math.round((deliveries / orders) * 100)}% Fulfilled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 px-1.5 py-0.5 rounded text-[9px] font-bold uppercase ${
|
||||
str(loc.status).toLowerCase() === 'active'
|
||||
className={`shrink-0 inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[9px] font-bold uppercase tracking-wide ${
|
||||
isActive
|
||||
? 'text-emerald-600 bg-emerald-50 border border-emerald-100'
|
||||
: 'text-zinc-500 bg-zinc-100'
|
||||
: 'text-slate-500 bg-slate-100 border border-slate-200'
|
||||
}`}
|
||||
>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${isActive ? 'bg-emerald-500' : 'bg-slate-400'}`} />
|
||||
{str(loc.status) || '—'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -4,29 +4,29 @@
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Menu, Plus, HelpCircle, LogOut } from 'lucide-react';
|
||||
import { Menu, HelpCircle, LogOut, ChevronDown, Mail } from 'lucide-react';
|
||||
import { MainSection } from '../types';
|
||||
|
||||
interface HeaderProps {
|
||||
currentSection: MainSection;
|
||||
setCurrentSection: (section: MainSection) => void;
|
||||
isCoimbatoreView: boolean;
|
||||
// Admin nav context — unused by the bar itself, optional so the Header can be
|
||||
// reused by the user store page which has no MainSection routing.
|
||||
currentSection?: MainSection;
|
||||
setCurrentSection?: (section: MainSection) => void;
|
||||
isCoimbatoreView?: boolean;
|
||||
onToggleSidebar: () => void;
|
||||
isSidebarOpen: boolean;
|
||||
onNewReportClick: () => void;
|
||||
onHelpClick: () => void;
|
||||
onLogoutClick: () => void;
|
||||
/** Signed-in user shown in the profile dropdown. */
|
||||
profile: { name: string; role: string; email: string };
|
||||
}
|
||||
|
||||
export default function Header({
|
||||
currentSection,
|
||||
setCurrentSection,
|
||||
isCoimbatoreView,
|
||||
onToggleSidebar,
|
||||
isSidebarOpen,
|
||||
onNewReportClick,
|
||||
onHelpClick,
|
||||
onLogoutClick
|
||||
onLogoutClick,
|
||||
profile
|
||||
}: HeaderProps) {
|
||||
const [showProfileDropdown, setShowProfileDropdown] = useState(false);
|
||||
const profileRef = useRef<HTMLDivElement>(null);
|
||||
@@ -47,12 +47,14 @@ export default function Header({
|
||||
};
|
||||
}, [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'
|
||||
};
|
||||
const initials =
|
||||
profile.name
|
||||
.split(' ')
|
||||
.map((w) => w[0])
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.join('')
|
||||
.toUpperCase() || 'NA';
|
||||
|
||||
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">
|
||||
@@ -83,52 +85,74 @@ export default function Header({
|
||||
<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"
|
||||
className="group flex items-center gap-2.5 pl-1 pr-1 sm:pr-2.5 py-1 rounded-full bg-white/10 hover:bg-white/15 border border-white/15 backdrop-blur-sm focus:ring-2 focus:ring-purple-300/60 outline-none cursor-pointer transition-all duration-150 active:scale-[0.98]"
|
||||
>
|
||||
<img
|
||||
src={profile.avatar}
|
||||
alt="Executive Profile"
|
||||
referrerPolicy="no-referrer"
|
||||
className="w-full h-full object-cover"
|
||||
{/* Initials avatar with live status dot */}
|
||||
<span className="relative shrink-0">
|
||||
<span className="w-9 h-9 rounded-full bg-white/15 ring-2 ring-white/30 flex items-center justify-center text-xs font-bold text-white tracking-wide">
|
||||
{initials}
|
||||
</span>
|
||||
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-emerald-400 ring-2 ring-[#581c87]" />
|
||||
</span>
|
||||
|
||||
{/* Identity (hidden on small screens) */}
|
||||
<span className="hidden sm:flex flex-col items-start leading-tight">
|
||||
<span className="text-xs font-bold text-white truncate max-w-[130px]">{profile.name}</span>
|
||||
<span className="text-[10px] text-purple-200 font-medium truncate max-w-[130px]">{profile.role}</span>
|
||||
</span>
|
||||
|
||||
<ChevronDown
|
||||
size={15}
|
||||
className={`hidden sm:block text-purple-200 transition-transform duration-200 ${showProfileDropdown ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</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 className="absolute right-0 mt-2.5 w-72 bg-white border border-slate-200/80 rounded-2xl shadow-2xl shadow-purple-950/15 z-50 text-slate-700 animate-in fade-in slide-in-from-top-2 duration-200 overflow-hidden">
|
||||
{/* Gradient profile header */}
|
||||
<div className="relative px-4 py-4 bg-gradient-to-br from-[#581c87] via-purple-800 to-purple-950 text-white overflow-hidden">
|
||||
<div className="absolute -top-8 -right-8 w-28 h-28 bg-purple-400/20 rounded-full blur-2xl pointer-events-none" />
|
||||
<div className="relative flex items-center gap-3">
|
||||
<span className="relative shrink-0">
|
||||
<span className="w-12 h-12 rounded-full bg-white/15 ring-2 ring-white/40 shadow-md flex items-center justify-center text-base font-bold text-white tracking-wide">
|
||||
{initials}
|
||||
</span>
|
||||
<span className="absolute bottom-0 right-0 w-3 h-3 rounded-full bg-emerald-400 ring-2 ring-purple-900" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="font-bold text-sm text-white truncate">{profile.name}</p>
|
||||
<span className="inline-block mt-1 text-[9px] font-bold uppercase tracking-wider text-purple-100 bg-white/15 border border-white/20 px-2 py-0.5 rounded-full">
|
||||
{profile.role}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative mt-3 flex items-center gap-1.5 text-[11px] text-purple-100 font-medium truncate">
|
||||
<Mail size={12} className="shrink-0 text-purple-200" />
|
||||
<span className="truncate">{profile.email}</span>
|
||||
</div>
|
||||
</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>
|
||||
{/* Account actions (moved here from the sidebar) */}
|
||||
<div className="p-2 flex flex-col gap-0.5">
|
||||
<button
|
||||
onClick={() => { setShowProfileDropdown(false); onHelpClick(); }}
|
||||
className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-xl text-xs font-semibold text-slate-700 hover:bg-slate-50 cursor-pointer transition-colors group/item"
|
||||
>
|
||||
<span className="h-7 w-7 rounded-lg bg-slate-100 text-slate-500 ring-1 ring-slate-200 flex items-center justify-center group-hover/item:scale-110 transition-transform">
|
||||
<HelpCircle size={14} />
|
||||
</span>
|
||||
Help Center
|
||||
</button>
|
||||
<div className="my-1 h-px bg-slate-100" />
|
||||
<button
|
||||
onClick={() => { setShowProfileDropdown(false); onLogoutClick(); }}
|
||||
className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-xl text-xs font-semibold text-rose-600 hover:bg-rose-50 cursor-pointer transition-colors group/item"
|
||||
>
|
||||
<span className="h-7 w-7 rounded-lg bg-rose-50 text-rose-500 ring-1 ring-rose-100 flex items-center justify-center group-hover/item:scale-110 transition-transform">
|
||||
<LogOut size={14} />
|
||||
</span>
|
||||
Log Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -19,25 +19,26 @@ import {
|
||||
Trash2,
|
||||
PackageCheck,
|
||||
ShieldCheck,
|
||||
Zap,
|
||||
Tag,
|
||||
UploadCloud,
|
||||
FileSpreadsheet,
|
||||
Palette,
|
||||
ShoppingBag,
|
||||
Info,
|
||||
X,
|
||||
Server,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
RotateCw,
|
||||
CheckCircle
|
||||
} from 'lucide-react';
|
||||
import { ProductMatrixItem, ImportLog } from '../types';
|
||||
import { initialImportLogs } from '../data';
|
||||
import { useFiestaTenantLocations, useFiestaStoresStock } from '../services/fiestaQueries';
|
||||
import { ProductMatrixItem } from '../types';
|
||||
import {
|
||||
useFiestaTenantLocations,
|
||||
useFiestaStoresStock,
|
||||
useFiestaProductCategories,
|
||||
} from '../services/fiestaQueries';
|
||||
import { FIESTA_TENANT_ID, str as fstr } from '../services/fiestaApi';
|
||||
import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers';
|
||||
import AwaitingApi from './AwaitingApi';
|
||||
|
||||
type StockRow = Record<string, unknown>;
|
||||
const rowId = (r: StockRow) => String(r.productid ?? '') || String(r.productname ?? '');
|
||||
@@ -78,7 +79,6 @@ export default function InventoryView({
|
||||
// Global catalog = deduped union of every outlet's products, plus anything the
|
||||
// admin adds/imports in-session. Seeded once from the live data.
|
||||
const [products, setProducts] = useState<ProductMatrixItem[]>([]);
|
||||
const [importLogs, setImportLogs] = useState<ImportLog[]>(initialImportLogs);
|
||||
const [seeded, setSeeded] = useState(false);
|
||||
|
||||
const allStoreRows = storesStock.flatMap((s) => s.rows);
|
||||
@@ -98,8 +98,8 @@ export default function InventoryView({
|
||||
const [showAddProductModal, setShowAddProductModal] = useState(false);
|
||||
const [outletFilter, setOutletFilter] = useState<'all' | 'alerts'>('all');
|
||||
const [outletSearch, setOutletSearch] = useState('');
|
||||
const [restockedOverrides, setRestockedOverrides] = useState<Record<number, Record<string, number>>>({});
|
||||
const [loadingOutlets, setLoadingOutlets] = useState<Record<number, boolean>>({});
|
||||
// Regional Hub Stocks is read-only for admins — overrides remain empty (no restock actions).
|
||||
const [restockedOverrides] = useState<Record<number, Record<string, number>>>({});
|
||||
const [expandedHubs, setExpandedHubs] = useState<Record<number, boolean>>({});
|
||||
|
||||
// Memoize storesStock query results merged with simulated restock overrides
|
||||
@@ -155,89 +155,11 @@ export default function InventoryView({
|
||||
};
|
||||
}, [storesStockWithOverrides]);
|
||||
|
||||
// Simulated restock dispatch handler
|
||||
const handleRestockOutlet = (locationId: number, storeRows: any[], locationName: string) => {
|
||||
setLoadingOutlets(prev => ({ ...prev, [locationId]: true }));
|
||||
|
||||
setTimeout(() => {
|
||||
setRestockedOverrides(prev => {
|
||||
const currentOverrides = prev[locationId] || {};
|
||||
const newOverrides = { ...currentOverrides };
|
||||
|
||||
storeRows.forEach(row => {
|
||||
const sku = `SKU-${String(row.productid ?? '') || String(row.productname ?? '')}`;
|
||||
newOverrides[sku] = 200; // Restock to safe optimal level (>= 120)
|
||||
});
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[locationId]: newOverrides
|
||||
};
|
||||
});
|
||||
|
||||
setLoadingOutlets(prev => ({ ...prev, [locationId]: false }));
|
||||
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
setImportLogs(prev => [
|
||||
{
|
||||
id: String(Date.now()),
|
||||
timestamp,
|
||||
file: 'SUPPLY_CHAIN_API',
|
||||
status: 'SUCCESS',
|
||||
count: storeRows.length,
|
||||
note: `AUTO-RESTOCK: Dispatched emergency shipment to ${locationName}. Synchronized ${storeRows.length} SKUs to 200 units.`
|
||||
},
|
||||
...prev
|
||||
]);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
// Simulated single SKU restock handler
|
||||
const handleRestockSKU = (locationId: number, row: any, locationName: string) => {
|
||||
if (!row) return;
|
||||
const sku = `SKU-${String(row.productid ?? '') || String(row.productname ?? '')}`;
|
||||
const prodName = String(row.productname || 'Unnamed product');
|
||||
|
||||
setRestockedOverrides(prev => {
|
||||
const currentOverrides = prev[locationId] || {};
|
||||
return {
|
||||
...prev,
|
||||
[locationId]: {
|
||||
...currentOverrides,
|
||||
[sku]: 200
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
setImportLogs(prev => [
|
||||
{
|
||||
id: String(Date.now()),
|
||||
timestamp,
|
||||
file: 'SUPPLY_CHAIN_API',
|
||||
status: 'SUCCESS',
|
||||
count: 1,
|
||||
note: `SKU-RESTOCK: Restocked ${prodName} (${sku}) at ${locationName} to 200 units.`
|
||||
},
|
||||
...prev
|
||||
]);
|
||||
};
|
||||
|
||||
// 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: '',
|
||||
@@ -248,6 +170,16 @@ export default function InventoryView({
|
||||
image: 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200'
|
||||
});
|
||||
|
||||
// Live product categories (for the Add-Product modal dropdown).
|
||||
const productCategoriesQ = useFiestaProductCategories();
|
||||
const productCategoryNames = useMemo(
|
||||
() =>
|
||||
(productCategoriesQ.data ?? [])
|
||||
.map((c) => fstr(c.categoryname))
|
||||
.filter((name): name is string => Boolean(name)),
|
||||
[productCategoriesQ.data],
|
||||
);
|
||||
|
||||
// Categories derived from the live catalog (falls back to ALL only).
|
||||
const categorySet = new Set<string>();
|
||||
products.forEach((p) => categorySet.add(p.category));
|
||||
@@ -342,78 +274,12 @@ export default function InventoryView({
|
||||
|
||||
if (parsedCount > 0) {
|
||||
setProducts(prev => [...newProds, ...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[] = [];
|
||||
|
||||
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
|
||||
});
|
||||
imported++;
|
||||
}
|
||||
});
|
||||
|
||||
if (imported > 0) {
|
||||
setProducts(prev => [...newProds, ...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 font-sans relative">
|
||||
|
||||
@@ -422,7 +288,7 @@ export default function InventoryView({
|
||||
<div className="absolute top-40 right-1/4 w-[28rem] h-[28rem] bg-indigo-400/5 rounded-full blur-[140px] pointer-events-none -z-10 animate-pulse" style={{ animationDuration: '8s' }} />
|
||||
|
||||
{/* ── Immersive Analytics Banner (With Catalog Cover Image & Slate Gradient Overlay) ── */}
|
||||
<div className="relative rounded-2xl p-6 md:p-8 text-white shadow-xl border border-purple-500/20 mb-8 animate-in fade-in duration-300 z-35">
|
||||
<div className="relative rounded-2xl p-6 md:p-8 text-white shadow-xl border border-purple-500/20 mb-8 animate-in fade-in duration-300 z-40">
|
||||
{/* Cover Image Background & Decor */}
|
||||
<div className="absolute inset-0 z-0 overflow-hidden rounded-2xl">
|
||||
<img
|
||||
@@ -438,11 +304,11 @@ export default function InventoryView({
|
||||
</div>
|
||||
|
||||
{/* Content Row */}
|
||||
<div className="relative z-10 flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<div className="relative z-20 flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<div>
|
||||
<h1 className="font-sans font-bold text-2xl tracking-tight text-white flex items-center gap-2">
|
||||
<Layers size={24} className="text-purple-300" />
|
||||
Product Catalog Command Center
|
||||
Product Catalog
|
||||
<span className="text-[10px] text-purple-200 font-bold bg-purple-900/60 border border-purple-500/30 px-2 py-0.5 rounded-full uppercase tracking-wider animate-pulse">
|
||||
Global Sync
|
||||
</span>
|
||||
@@ -710,19 +576,22 @@ export default function InventoryView({
|
||||
|
||||
{/* Elegant Header Row */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b border-slate-100 pb-4 mt-8 select-none">
|
||||
<div className="space-y-1 font-sans">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-bold text-sm text-slate-900 flex items-center gap-1.5">
|
||||
<Server size={16} className="text-purple-650" /> Regional Hub Stocks
|
||||
</h3>
|
||||
<span className="inline-flex items-center gap-1 text-[9px] font-bold bg-emerald-50 text-emerald-600 border border-emerald-100/50 px-2 py-0.5 rounded-full">
|
||||
<span className="w-1 h-1 rounded-full bg-emerald-500 animate-pulse" />
|
||||
Live Sync
|
||||
</span>
|
||||
<div className="flex items-start gap-3 font-sans">
|
||||
<div className="h-10 w-10 shrink-0 rounded-xl bg-gradient-to-br from-purple-500 to-indigo-500 text-white flex items-center justify-center shadow-sm">
|
||||
<Server size={18} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-bold text-base text-slate-900 tracking-tight">Regional Hub Stocks</h3>
|
||||
<span className="inline-flex items-center gap-1 text-[9px] font-bold bg-emerald-50 text-emerald-600 border border-emerald-100/50 px-2 py-0.5 rounded-full">
|
||||
<span className="w-1 h-1 rounded-full bg-emerald-500 animate-pulse" />
|
||||
Live Sync
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-slate-400 text-[11px] font-medium">
|
||||
Real-time inventory levels and capacity balance across {locations.length} regional outlets.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-slate-400 text-[10px] font-medium">
|
||||
Real-time inventory levels and capacity balance across {locations.length} regional outlets.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Controls: Search + Filters */}
|
||||
@@ -779,23 +648,29 @@ export default function InventoryView({
|
||||
</div>
|
||||
|
||||
{/* Quick Metrics Strip */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 p-4 bg-slate-50/50 rounded-2xl border border-slate-100/80 font-sans text-xs">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[9px] text-slate-400 uppercase tracking-wider font-extrabold">Active Outlets</span>
|
||||
<span className="font-bold text-slate-800 text-sm font-mono">{locations.length}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[9px] text-slate-400 uppercase tracking-wider font-extrabold">Optimal Hubs</span>
|
||||
<span className="font-bold text-emerald-600 text-sm font-mono">{locations.length - storeAlertsData.alertOutletsCount}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[9px] text-slate-400 uppercase tracking-wider font-extrabold">Low Stock Items</span>
|
||||
<span className="font-bold text-amber-600 text-sm font-mono">{storeAlertsData.lowStockCount}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[9px] text-slate-400 uppercase tracking-wider font-extrabold">Critical Alerts</span>
|
||||
<span className="font-bold text-rose-600 text-sm font-mono">{storeAlertsData.criticalCount}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 font-sans">
|
||||
{[
|
||||
{ label: 'Active Outlets', value: locations.length, icon: Server, chip: 'bg-purple-50 text-purple-650 ring-purple-100', value_cls: 'text-slate-900' },
|
||||
{ label: 'Optimal Hubs', value: locations.length - storeAlertsData.alertOutletsCount, icon: CheckCircle, chip: 'bg-emerald-50 text-emerald-600 ring-emerald-100', value_cls: 'text-emerald-600' },
|
||||
{ label: 'Low Stock Items', value: storeAlertsData.lowStockCount, icon: TrendingDown, chip: 'bg-amber-50 text-amber-600 ring-amber-100', value_cls: 'text-amber-600' },
|
||||
{ label: 'Critical Alerts', value: storeAlertsData.criticalCount, icon: AlertTriangle, chip: 'bg-rose-50 text-rose-600 ring-rose-100', value_cls: 'text-rose-600' },
|
||||
].map((m) => {
|
||||
const MIcon = m.icon;
|
||||
return (
|
||||
<div
|
||||
key={m.label}
|
||||
className="bg-white border border-slate-200/70 rounded-2xl p-4 shadow-[0_1px_2px_rgba(16,24,40,0.04)] hover:shadow-[0_8px_22px_rgba(16,24,40,0.08)] hover:border-purple-200 transition-all duration-300 flex items-center gap-3"
|
||||
>
|
||||
<div className={`h-10 w-10 shrink-0 rounded-xl flex items-center justify-center ring-1 ${m.chip}`}>
|
||||
<MIcon size={18} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<span className="block text-[9px] text-slate-400 uppercase tracking-wider font-extrabold truncate">{m.label}</span>
|
||||
<span className={`block font-extrabold text-2xl leading-tight font-sans tracking-tight ${m.value_cls}`}>{m.value}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
@@ -833,10 +708,14 @@ export default function InventoryView({
|
||||
const meta = locations.find((l) => l.locationid === store.locationid);
|
||||
const status = meta?.status ?? 'Active';
|
||||
|
||||
const statusDotColor = hasAlert
|
||||
? criticalItemsCount > 0 ? 'bg-rose-500' : 'bg-amber-500'
|
||||
const statusDotColor = hasAlert
|
||||
? criticalItemsCount > 0 ? 'bg-rose-500' : 'bg-amber-500'
|
||||
: 'bg-emerald-500';
|
||||
|
||||
const statusChip = hasAlert
|
||||
? criticalItemsCount > 0 ? 'bg-rose-50 text-rose-600 ring-rose-100' : 'bg-amber-50 text-amber-600 ring-amber-100'
|
||||
: 'bg-emerald-50 text-emerald-600 ring-emerald-100';
|
||||
|
||||
const optimalPct = totalItems > 0 ? (optimalCount / totalItems) * 100 : 0;
|
||||
const lowPct = totalItems > 0 ? (lowCount / totalItems) * 100 : 0;
|
||||
const criticalPct = totalItems > 0 ? (criticalItemsCount / totalItems) * 100 : 0;
|
||||
@@ -848,72 +727,71 @@ export default function InventoryView({
|
||||
});
|
||||
|
||||
return (
|
||||
<div key={store.locationid} className="bg-white border border-slate-100 rounded-2xl shadow-sm hover:shadow-md transition-all duration-300 relative flex flex-col justify-between overflow-hidden min-h-[300px]">
|
||||
|
||||
{/* Loading Overlay */}
|
||||
{loadingOutlets[store.locationid] && (
|
||||
<div className="absolute inset-0 bg-white/95 backdrop-blur-sm z-40 flex flex-col items-center justify-center p-6 text-center animate-in fade-in duration-200">
|
||||
<div className="space-y-3">
|
||||
<div className="w-8 h-8 mx-auto relative">
|
||||
<div className="absolute inset-0 rounded-full border-2 border-purple-500/20" />
|
||||
<div className="absolute inset-0 rounded-full border-2 border-purple-600 border-t-transparent animate-spin" />
|
||||
</div>
|
||||
<div className="space-y-0.5 font-sans">
|
||||
<h4 className="text-slate-850 font-bold text-[11px]">Replenishing Hub...</h4>
|
||||
<p className="text-slate-400 text-[9px]">Dispatching supply batch to {store.locationname}</p>
|
||||
</div>
|
||||
<div key={store.locationid} className="bg-white border border-slate-200/70 rounded-2xl shadow-[0_1px_3px_rgba(16,24,40,0.05)] hover:shadow-[0_16px_36px_rgba(16,24,40,0.10)] hover:border-purple-200 hover:-translate-y-0.5 transition-all duration-300 relative flex flex-col justify-between overflow-hidden min-h-[300px]">
|
||||
|
||||
{/* Card Header */}
|
||||
<div className="p-4 pb-3 flex justify-between items-start gap-2">
|
||||
<div className="flex items-start gap-2.5 min-w-0">
|
||||
<div className={`h-9 w-9 shrink-0 rounded-lg flex items-center justify-center ring-1 ${statusChip}`}>
|
||||
<Server size={15} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h4 className="font-bold text-slate-900 text-[13px] truncate leading-tight">
|
||||
{store.locationname}
|
||||
</h4>
|
||||
<p className="text-[10px] text-slate-400 mt-0.5 font-medium">
|
||||
{totalItems} items · {totalUnits.toLocaleString('en-IN')} units
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Card Header (Clean & borderless) */}
|
||||
<div className="p-4 pb-2 flex justify-between items-start gap-2">
|
||||
<div className="min-w-0">
|
||||
<h4 className="font-bold text-slate-900 text-xs truncate flex items-center gap-1.5">
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${statusDotColor}`} />
|
||||
{store.locationname}
|
||||
</h4>
|
||||
<p className="text-[9px] text-slate-400 mt-0.5 font-medium">
|
||||
{totalItems} items · {totalUnits.toLocaleString('en-IN')} units
|
||||
</p>
|
||||
</div>
|
||||
<span className={`shrink-0 px-2 py-0.5 rounded-full text-[8px] font-black uppercase tracking-wider ${
|
||||
<span className={`shrink-0 inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[8px] font-black uppercase tracking-wider ${
|
||||
hasAlert
|
||||
? criticalItemsCount > 0
|
||||
? 'text-rose-600 bg-rose-50'
|
||||
: 'text-amber-700 bg-amber-50'
|
||||
: 'text-emerald-600 bg-emerald-50'
|
||||
? criticalItemsCount > 0
|
||||
? 'text-rose-600 bg-rose-50 border border-rose-100'
|
||||
: 'text-amber-700 bg-amber-50 border border-amber-100'
|
||||
: 'text-emerald-600 bg-emerald-50 border border-emerald-100'
|
||||
}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${statusDotColor}`} />
|
||||
{hasAlert ? criticalItemsCount > 0 ? 'Critical' : 'Low Stock' : 'Optimal'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Card Body */}
|
||||
<div className="px-4 py-2 space-y-3 flex-1">
|
||||
|
||||
<div className="px-4 py-2 space-y-3.5 flex-1">
|
||||
|
||||
{/* Segmented Stock Health Distribution */}
|
||||
<div className="w-full h-1 bg-slate-100 rounded-full overflow-hidden flex select-none">
|
||||
{criticalPct > 0 && (
|
||||
<div className="h-full bg-rose-500" style={{ width: `${criticalPct}%` }} />
|
||||
)}
|
||||
{lowPct > 0 && (
|
||||
<div className="h-full bg-amber-500" style={{ width: `${lowPct}%` }} />
|
||||
)}
|
||||
{optimalPct > 0 && (
|
||||
<div className="h-full bg-emerald-500" style={{ width: `${optimalPct}%` }} />
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between items-center text-[9px] font-bold text-slate-400 uppercase tracking-wider">
|
||||
<span>Stock Health</span>
|
||||
<span className="flex items-center gap-2 normal-case font-semibold">
|
||||
{criticalItemsCount > 0 && <span className="text-rose-600">{criticalItemsCount} crit</span>}
|
||||
{lowCount > 0 && <span className="text-amber-600">{lowCount} low</span>}
|
||||
<span className="text-emerald-600">{optimalCount} ok</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-slate-100 rounded-full overflow-hidden flex select-none">
|
||||
{criticalPct > 0 && (
|
||||
<div className="h-full bg-rose-500" style={{ width: `${criticalPct}%` }} />
|
||||
)}
|
||||
{lowPct > 0 && (
|
||||
<div className="h-full bg-amber-500" style={{ width: `${lowPct}%` }} />
|
||||
)}
|
||||
{optimalPct > 0 && (
|
||||
<div className="h-full bg-emerald-500" style={{ width: `${optimalPct}%` }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Capacity utilization indicator */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between items-center text-[9px] font-medium text-slate-400">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between items-center text-[9px] font-bold text-slate-400 uppercase tracking-wider">
|
||||
<span>Capacity Utilised</span>
|
||||
<span className="font-mono text-slate-600">{Math.round(capacityPct)}%</span>
|
||||
<span className="font-mono text-slate-600 normal-case font-bold">{Math.round(capacityPct)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-100 h-1 rounded-full overflow-hidden">
|
||||
<div
|
||||
<div className="w-full bg-slate-100 h-1.5 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${
|
||||
capacityPct > 85 ? 'bg-rose-500' : 'bg-purple-650'
|
||||
capacityPct > 85 ? 'bg-rose-500' : 'bg-gradient-to-r from-purple-500 to-indigo-500'
|
||||
}`}
|
||||
style={{ width: `${capacityPct}%` }}
|
||||
/>
|
||||
@@ -931,11 +809,10 @@ export default function InventoryView({
|
||||
) : (
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto pr-0.5 scrollbar-thin">
|
||||
{sortedItems.map((it, idx) => {
|
||||
const rawRow = store.rows.find(r => `SKU-${String(r.productid ?? '') || String(r.productname ?? '')}` === it.sku);
|
||||
const isLow = it.status !== 'Optimal';
|
||||
|
||||
return (
|
||||
<div key={idx} className="flex justify-between items-center gap-3 py-0.5 group/row">
|
||||
<div key={idx} className="flex justify-between items-center gap-3 py-0.5">
|
||||
<div className="min-w-0 flex items-center gap-2">
|
||||
<span className={`w-1 h-1 rounded-full shrink-0 ${
|
||||
it.status === 'Critical' ? 'bg-rose-500 animate-pulse' : it.status === 'Low Stock' ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500'
|
||||
@@ -946,23 +823,12 @@ export default function InventoryView({
|
||||
{it.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className={`font-mono text-[10px] font-bold ${
|
||||
it.status === 'Critical' ? 'text-rose-600' : it.status === 'Low Stock' ? 'text-amber-700' : 'text-slate-500'
|
||||
}`}>
|
||||
{it.stockLevel}
|
||||
</span>
|
||||
{isLow && (
|
||||
<button
|
||||
onClick={() => handleRestockSKU(store.locationid, rawRow, store.locationname)}
|
||||
title="Replenish SKU"
|
||||
className="p-0.5 rounded hover:bg-purple-50 text-slate-400 hover:text-purple-650 transition-colors border-none bg-transparent cursor-pointer opacity-0 group-hover/row:opacity-100 focus:opacity-100"
|
||||
>
|
||||
<RotateCw size={9} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className={`font-mono text-[10px] font-bold shrink-0 ${
|
||||
it.status === 'Critical' ? 'text-rose-600' : it.status === 'Low Stock' ? 'text-amber-700' : 'text-slate-500'
|
||||
}`}>
|
||||
{it.stockLevel}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -972,20 +838,18 @@ export default function InventoryView({
|
||||
|
||||
</div>
|
||||
|
||||
{/* Card Footer actions */}
|
||||
{/* Card Footer — read-only status (admins cannot edit hub stock) */}
|
||||
<div className="px-4 py-3 border-t border-slate-100 bg-slate-50/50 flex items-center justify-between gap-2 shrink-0">
|
||||
<span className="text-[9px] font-mono text-slate-400 uppercase tracking-wide">
|
||||
{hasAlert ? `${criticalItemsCount + lowCount} items need attention` : 'All items optimal'}
|
||||
</span>
|
||||
|
||||
{hasAlert ? (
|
||||
<button
|
||||
onClick={() => handleRestockOutlet(store.locationid, store.rows, store.locationname)}
|
||||
className="bg-purple-650 hover:bg-purple-755 text-white px-3 py-1.5 rounded-lg text-[9px] font-bold uppercase tracking-wider cursor-pointer border-none transition-all flex items-center gap-1 shadow-sm active:scale-95"
|
||||
>
|
||||
<Zap size={10} />
|
||||
<span>Restock Hub</span>
|
||||
</button>
|
||||
<span className={`text-[9px] font-black flex items-center gap-1 select-none pr-1 uppercase tracking-wide ${
|
||||
criticalItemsCount > 0 ? 'text-rose-600' : 'text-amber-700'
|
||||
}`}>
|
||||
<AlertTriangle size={11} /> {criticalItemsCount > 0 ? 'Critical' : 'Low Stock'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[9px] font-black text-emerald-600 flex items-center gap-1 select-none pr-1">
|
||||
<CheckCircle size={11} /> Stocked
|
||||
@@ -1013,45 +877,7 @@ export default function InventoryView({
|
||||
<h3>Cooperative Catalog Presets</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-sm">
|
||||
|
||||
{/* Preset 1 */}
|
||||
<div className="border border-[#e2e8f0] rounded-2xl p-md space-y-md hover:border-purple-300 transition-colors bg-gradient-to-br from-indigo-50/20 to-purple-50/10 group">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[8px] font-extrabold uppercase bg-purple-100 text-purple-700 px-2 py-0.5 rounded-md">Cooperative Dairy</span>
|
||||
<h4 className="font-bold text-zinc-900 leading-tight pt-1">Nilgiris Dairy Fresh Pack</h4>
|
||||
<p className="text-[10px] text-zinc-400 font-semibold">3 High-Margin Butter & Cheese SKUs</p>
|
||||
</div>
|
||||
<div className="flex justify-between items-center pt-2">
|
||||
<span className="text-[10px] font-mono font-bold text-[#581c87]">CBE-COOP-04</span>
|
||||
<button
|
||||
onClick={() => handleImportPreset('Nilgiris Dairy Coop', nilgirisDairy)}
|
||||
className="px-3 py-1.5 bg-[#581c87] hover:bg-purple-800 text-white font-bold rounded-lg text-[9px] uppercase cursor-pointer transition-transform group-hover:scale-105"
|
||||
>
|
||||
Import Batch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preset 2 */}
|
||||
<div className="border border-[#e2e8f0] rounded-2xl p-md space-y-md hover:border-purple-300 transition-colors bg-gradient-to-br from-emerald-50/20 to-teal-50/10 group">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[8px] font-extrabold uppercase bg-emerald-100 text-emerald-700 px-2 py-0.5 rounded-md">Agricultural Feed</span>
|
||||
<h4 className="font-bold text-zinc-900 leading-tight pt-1">Coimbatore Heritage Grains</h4>
|
||||
<p className="text-[10px] text-zinc-400 font-semibold">3 Premium Boiled Rice & Oils</p>
|
||||
</div>
|
||||
<div className="flex justify-between items-center pt-2">
|
||||
<span className="text-[10px] font-mono font-bold text-emerald-600">TAMIL-AGRI-09</span>
|
||||
<button
|
||||
onClick={() => handleImportPreset('Coimbatore Heritage', cbeHeritage)}
|
||||
className="px-3 py-1.5 bg-[#581c87] hover:bg-purple-800 text-white font-bold rounded-lg text-[9px] uppercase cursor-pointer transition-transform group-hover:scale-105"
|
||||
>
|
||||
Import Batch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<AwaitingApi label="Catalog presets" api="[R5]" compact />
|
||||
</div>
|
||||
|
||||
{/* Custom CSV Parsing Box */}
|
||||
@@ -1097,26 +923,7 @@ export default function InventoryView({
|
||||
<span className="text-[9px] text-zinc-500 font-bold">COIMBATORE_ERP_V4</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto pr-1">
|
||||
{importLogs.map((log, idx) => (
|
||||
<div key={idx} className="flex justify-between items-start gap-4 text-[10px] border-b border-zinc-900/50 pb-1.5">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-purple-400 font-extrabold">{log.batchRef}</span>
|
||||
<span className="text-zinc-650 text-[8px] font-semibold">{log.timestamp}</span>
|
||||
</div>
|
||||
<p className="text-zinc-400">
|
||||
{log.type} via <span className="text-zinc-300 italic">{log.source}</span>
|
||||
</p>
|
||||
<p className="text-zinc-505 font-sans text-[9px] leading-tight">{log.result}</p>
|
||||
</div>
|
||||
|
||||
<span className="px-1.5 py-0.5 bg-emerald-950/50 border border-emerald-900 text-emerald-450 font-extrabold uppercase text-[8px] rounded">
|
||||
{log.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<AwaitingApi label="Import / sync audit log" api="[R4]" compact className="bg-zinc-900/40 border-zinc-800" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -1130,115 +937,7 @@ export default function InventoryView({
|
||||
<h3>Packaging Branding Studio</h3>
|
||||
</div>
|
||||
|
||||
<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 Title 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] font-semibold text-zinc-700"
|
||||
/>
|
||||
</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 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 bg-transparent"
|
||||
/>
|
||||
<span className="font-mono font-bold text-zinc-650">{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 bg-transparent"
|
||||
/>
|
||||
<span className="font-mono font-bold text-zinc-650">{brandStyle.secondaryColor}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Studio Control 3 */}
|
||||
<div className="space-y-1">
|
||||
<label className="font-bold text-zinc-505 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] font-semibold text-zinc-700"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Studio Control 4 */}
|
||||
<div className="flex items-center justify-between p-sm bg-slate-50/50 border border-slate-200 rounded-xl">
|
||||
<div>
|
||||
<h4 className="font-bold text-[#0f172a] text-xs">Sustainability Seal</h4>
|
||||
<p className="text-[9px] text-zinc-400 font-semibold mt-0.5">Include Coimbatore eco certification stamp</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={brandStyle.isEcoVerified}
|
||||
onChange={() => setBrandStyle({ ...brandStyle, isEcoVerified: !brandStyle.isEcoVerified })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-8 h-4.5 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-350 after:border after:rounded-full after:h-3.5 after:w-3.5 after:transition-all peer-checked:bg-emerald-500 shadow-inner"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Interactive Dynamic Checkout Jute Bag Preview Canvas */}
|
||||
<div className="border border-[#e2e8f0] rounded-2xl p-md bg-[#faf8f5] space-y-sm shadow-inner flex flex-col items-center">
|
||||
<span className="text-[9px] font-sans font-bold text-zinc-405 uppercase tracking-widest block text-center border-b border-zinc-200/50 pb-1.5 w-full">
|
||||
Package Bag Design Preview
|
||||
</span>
|
||||
|
||||
<div className="relative mx-auto w-48 h-64 bg-[#eddcd2] border-2 border-[#d6b795] rounded-b-3xl rounded-t-xl shadow-2xl flex flex-col justify-between p-md mt-6 mb-2 overflow-hidden animate-in fade-in zoom-in-95 duration-500">
|
||||
{/* Realistic Jute Texture Overlay simulation */}
|
||||
<div className="absolute inset-0 bg-gradient-to-tr from-stone-900/5 to-amber-950/5 pointer-events-none" />
|
||||
|
||||
{/* Hanging handle simulation */}
|
||||
<div className="absolute -top-3.5 left-1/2 -translate-x-1/2 w-20 h-6 border-3 border-b-0 border-[#c4a27d] rounded-t-full shadow-sm" />
|
||||
|
||||
<div className="text-center pt-sm space-y-1 relative z-10">
|
||||
<span className="text-[11px] font-extrabold block tracking-tight uppercase" style={{ color: brandStyle.primaryColor }}>
|
||||
{brandStyle.themeName || 'nearledaily Fresh'}
|
||||
</span>
|
||||
<div className="w-12 h-0.5 mx-auto rounded-full" style={{ backgroundColor: brandStyle.secondaryColor }} />
|
||||
</div>
|
||||
|
||||
<div className="my-auto flex flex-col items-center text-center p-1 space-y-1.5 relative z-10">
|
||||
<ShoppingBag className="w-12 h-12 stroke-1 drop-shadow-sm" style={{ color: brandStyle.primaryColor }} />
|
||||
<span className="text-[9px] font-extrabold max-w-[130px] leading-tight block text-stone-750">
|
||||
{brandStyle.bagLabel || 'Grown with Pride'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center text-[7px] border-t border-stone-300 pt-1.5 relative z-10 font-bold text-stone-500">
|
||||
<span>100% ORGANIC</span>
|
||||
{brandStyle.isEcoVerified && (
|
||||
<span className="text-emerald-800 font-extrabold bg-emerald-100/80 border border-emerald-200 px-1.5 py-0.5 rounded text-[6px]">
|
||||
CBE-ECO
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<AwaitingApi label="Brand & packaging config" api="[R6]" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -1302,10 +1001,13 @@ export default function InventoryView({
|
||||
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>
|
||||
{/* Keep the form's default selection valid even if it isn't in the live list. */}
|
||||
{!productCategoryNames.includes(newProduct.category) && (
|
||||
<option value={newProduct.category}>{newProduct.category}</option>
|
||||
)}
|
||||
{productCategoryNames.map((name) => (
|
||||
<option key={name} value={name}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
296
src/components/LoginView.tsx
Normal file
296
src/components/LoginView.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
ShieldCheck,
|
||||
Mail,
|
||||
Lock,
|
||||
ArrowRight,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { checkEmailRequest } from '../services/auth';
|
||||
import type { AuthUser } from '../services/auth';
|
||||
import { useLogin } from '../services/fiestaQueries';
|
||||
|
||||
interface LoginViewProps {
|
||||
/** Called with the authenticated user once credentials are verified. */
|
||||
onLogin: (user: AuthUser) => void;
|
||||
}
|
||||
|
||||
export default function LoginView({ onLogin }: LoginViewProps) {
|
||||
// A single login form. The backend's role on the verified user decides the
|
||||
// workspace (admin vs user) the account actually lands on.
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [step, setStep] = useState<'email' | 'password'>('email');
|
||||
const [checkingEmail, setCheckingEmail] = useState(false);
|
||||
|
||||
const login = useLogin();
|
||||
const loading = login.isPending;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (loading || checkingEmail) return;
|
||||
|
||||
if (step === 'email') {
|
||||
if (!email.trim()) {
|
||||
setError('Please enter your email.');
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setCheckingEmail(true);
|
||||
try {
|
||||
const emailExists = await checkEmailRequest(email);
|
||||
if (emailExists) {
|
||||
setStep('password');
|
||||
} else {
|
||||
setError('Email not found. Please try again.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to verify email. Please try again.');
|
||||
} finally {
|
||||
setCheckingEmail(false);
|
||||
}
|
||||
} else {
|
||||
if (!password.trim()) {
|
||||
setError('Please enter your password.');
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
// Verify credentials against the backend via the TanStack mutation. Only an
|
||||
// exact match resolves; the returned role routes admin vs user workspace.
|
||||
login.mutate(
|
||||
{ email, password },
|
||||
{
|
||||
onSuccess: (user) => onLogin(user),
|
||||
onError: (err) =>
|
||||
setError(err instanceof Error ? err.message : 'Sign in failed. Please try again.'),
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full flex bg-white font-sans text-slate-800 overflow-hidden">
|
||||
{/* ── Left brand / hero panel (desktop only) ── */}
|
||||
<div className="hidden lg:flex lg:w-1/2 shrink-0 relative overflow-hidden bg-gradient-to-br from-[#5b1d8c] via-purple-950 to-slate-950 text-white flex-col p-12 xl:p-16 justify-center">
|
||||
{/* Layered ambient glows */}
|
||||
<div className="absolute -top-24 -right-24 w-96 h-96 bg-purple-500/25 rounded-full blur-[120px] pointer-events-none" />
|
||||
<div className="absolute -bottom-24 -left-24 w-[26rem] h-[26rem] bg-indigo-500/20 rounded-full blur-[140px] pointer-events-none animate-pulse" style={{ animationDuration: '9s' }} />
|
||||
<div className="absolute top-1/3 left-1/2 w-72 h-72 bg-fuchsia-500/10 rounded-full blur-[120px] pointer-events-none" />
|
||||
{/* Subtle dot-grid texture */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.07] pointer-events-none"
|
||||
style={{ backgroundImage: 'radial-gradient(circle, #ffffff 1px, transparent 1px)', backgroundSize: '22px 22px' }}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 flex flex-col justify-center w-full max-w-[520px] mx-auto h-full">
|
||||
{/* Logo */}
|
||||
<div className="shrink-0 mb-10">
|
||||
<img src="/logo.png" alt="nearledaily" className="h-10 w-auto object-contain" />
|
||||
</div>
|
||||
|
||||
{/* Hero */}
|
||||
<div className="min-h-0">
|
||||
{/* Eyebrow */}
|
||||
<span className="inline-flex items-center gap-1.5 self-start text-[11px] font-bold uppercase tracking-[0.15em] text-purple-100 bg-white/10 border border-white/15 rounded-full px-3 py-1 backdrop-blur-sm">
|
||||
<ShieldCheck size={13} className="text-purple-200" />
|
||||
Secure Merchant Console
|
||||
</span>
|
||||
|
||||
<h1
|
||||
className="text-5xl xl:text-6xl font-bold tracking-tight leading-[1.1] mt-6 max-w-[460px]"
|
||||
style={{ textShadow: '0 2px 16px rgba(0,0,0,0.25)' }}
|
||||
>
|
||||
All Operations
|
||||
<br />
|
||||
In One Console
|
||||
</h1>
|
||||
<p className="text-purple-100/90 text-lg xl:text-xl leading-[1.6] mt-6 max-w-[460px]">
|
||||
Monitor regional hubs, track live order operations, and manage your entire store network from a single command center.
|
||||
</p>
|
||||
|
||||
{/* Glassmorphic live-snapshot preview card */}
|
||||
<div className="mt-10 max-w-[460px] rounded-2xl bg-white/10 backdrop-blur-md border border-white/15 p-6 shadow-2xl shadow-purple-950/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-purple-100">Live Operations</span>
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-bold text-emerald-300">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||
LIVE
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<p className="text-3xl font-extrabold tracking-tight tabular-nums">2,480</p>
|
||||
<p className="text-xs text-purple-200 mt-1">Orders today</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-3xl font-extrabold tracking-tight text-emerald-300 tabular-nums">98.6%</p>
|
||||
<p className="text-xs text-purple-200 mt-1">Fulfillment</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mini bar chart */}
|
||||
<div className="mt-5 flex items-end gap-1.5 h-14">
|
||||
{[45, 62, 38, 70, 52, 80, 60, 90, 55, 74].map((h, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 rounded-t bg-gradient-to-t from-purple-500/40 to-purple-200/80"
|
||||
style={{ height: `${h}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trust stats + copyright */}
|
||||
<div className="shrink-0 mt-10 pt-6 border-t border-white/10 max-w-[460px]">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{[
|
||||
{ v: '120+', l: 'Regional hubs' },
|
||||
{ v: '99.9%', l: 'Platform uptime' },
|
||||
{ v: '24/7', l: 'Live monitoring' },
|
||||
].map((s) => (
|
||||
<div key={s.l}>
|
||||
<p className="text-2xl font-bold tracking-tight">{s.v}</p>
|
||||
<p className="text-xs text-purple-300 mt-0.5">{s.l}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-purple-300/70 font-medium mt-4">
|
||||
© 2026 nearledaily · All rights reserved
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Right form panel ── */}
|
||||
<div className="w-full lg:w-1/2 flex items-center justify-center px-6 py-8 sm:px-10 overflow-y-auto">
|
||||
<div className="w-full max-w-[400px]">
|
||||
{/* Logo / brand (mobile) */}
|
||||
<div className="lg:hidden mb-8">
|
||||
<img src="/logo.png" alt="nearledaily" className="h-9 w-auto object-contain" />
|
||||
</div>
|
||||
|
||||
{/* Heading */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-[1.7rem] font-bold text-slate-900 tracking-tight leading-tight">Welcome back</h2>
|
||||
<p className="text-slate-500 text-sm mt-2">Sign in to your nearledaily workspace to continue.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Email or username (the API's `authname` accepts either) */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<label htmlFor="login-email" className="block text-xs font-bold text-slate-600 uppercase tracking-wider">
|
||||
Email or Username
|
||||
</label>
|
||||
{step === 'password' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setStep('email'); setError(''); setPassword(''); }}
|
||||
className="text-xs font-semibold text-purple-600 hover:text-purple-800 bg-transparent border-none cursor-pointer p-0"
|
||||
>
|
||||
Change Email
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Mail size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
|
||||
<input
|
||||
id="login-email"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
disabled={step === 'password'}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@nearledaily.com"
|
||||
className="w-full h-12 pl-10 pr-4 bg-slate-50 border border-slate-200 rounded-xl text-sm text-slate-800 placeholder-slate-400 focus:outline-none focus:border-purple-500 focus:bg-white focus:ring-4 focus:ring-purple-500/10 transition-all disabled:opacity-75 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
{step === 'password' && (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="login-password" className="block text-xs font-bold text-slate-600 uppercase tracking-wider">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
|
||||
<input
|
||||
id="login-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full h-12 pl-10 pr-11 bg-slate-50 border border-slate-200 rounded-xl text-sm text-slate-800 placeholder-slate-400 focus:outline-none focus:border-purple-500 focus:bg-white focus:ring-4 focus:ring-purple-500/10 transition-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((s) => !s)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 bg-transparent border-none cursor-pointer p-1"
|
||||
title={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs font-semibold text-rose-600 bg-rose-50 border border-rose-100 rounded-lg px-3 py-2.5">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Options row */}
|
||||
{step === 'password' && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<label className="flex items-center gap-2 text-slate-500 font-medium cursor-pointer select-none">
|
||||
<input type="checkbox" className="h-3.5 w-3.5 rounded border-slate-300 text-purple-600 accent-purple-600 focus:ring-purple-500/30" />
|
||||
Remember me
|
||||
</label>
|
||||
<button type="button" className="font-semibold text-purple-650 hover:text-purple-800 bg-transparent border-none cursor-pointer">
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || checkingEmail}
|
||||
className="w-full h-12 flex items-center justify-center gap-2 bg-[#581c87] hover:bg-purple-800 text-white font-bold text-sm rounded-xl shadow-sm hover:shadow-lg transition-all active:scale-[0.98] cursor-pointer border-none disabled:opacity-70 disabled:cursor-not-allowed disabled:active:scale-100"
|
||||
>
|
||||
{loading || checkingEmail ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
{checkingEmail ? 'Checking Email…' : 'Verifying…'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{step === 'email' ? 'Continue' : 'Sign in'}
|
||||
<ArrowRight size={16} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Helper note */}
|
||||
<p className="text-center text-xs text-slate-400 mt-6 leading-relaxed">
|
||||
Your workspace is set automatically based on your account access.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,12 +19,9 @@ import {
|
||||
XCircle,
|
||||
FolderSync,
|
||||
UploadCloud,
|
||||
FileCheck,
|
||||
Download,
|
||||
AlertOctagon,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { initialImportLogs } from '../data';
|
||||
import { InventoryItem } from '../types';
|
||||
import {
|
||||
useFiestaStockStatement,
|
||||
@@ -32,6 +29,7 @@ import {
|
||||
} from '../services/fiestaQueries';
|
||||
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, num as fnum, str as fstr } from '../services/fiestaApi';
|
||||
import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers';
|
||||
import AwaitingApi from './AwaitingApi';
|
||||
|
||||
interface OperationsViewProps {
|
||||
searchQuery: string;
|
||||
@@ -66,7 +64,6 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
||||
// 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 [importLogs, setImportLogs] = useState(initialImportLogs);
|
||||
|
||||
useEffect(() => {
|
||||
if (stockQ.data) {
|
||||
@@ -253,10 +250,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
||||
<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>
|
||||
<AwaitingApi label="Fulfillment health" api="[R1]" compact className="mt-xs" />
|
||||
</div>
|
||||
<div className="p-2.5 rounded-lg bg-emerald-50 text-emerald-600 animate-pulse">
|
||||
<PackageCheck size={18} />
|
||||
@@ -376,12 +370,14 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
||||
<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>
|
||||
<AwaitingApi
|
||||
label="Forecast insights"
|
||||
api="[R7]"
|
||||
compact
|
||||
className="mt-sm bg-white/5 border-white/15 text-zinc-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Embedded SVG graphic visual */}
|
||||
<div className="absolute right-3 bottom-3 opacity-15">
|
||||
<PackageCheck size={64} className="text-purple-300" />
|
||||
@@ -451,7 +447,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
||||
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.
|
||||
Global inventory master list and exposure levels across {productList.length.toLocaleString('en-IN')} nodes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -549,19 +545,10 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
||||
{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
|
||||
<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.');
|
||||
}
|
||||
}}
|
||||
@@ -602,50 +589,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
||||
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>
|
||||
<AwaitingApi label="Import audit & validation" api="[R4]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
UserCheck,
|
||||
MapPin,
|
||||
TrendingUp,
|
||||
Plus,
|
||||
ChevronRight,
|
||||
Package,
|
||||
ArrowRight,
|
||||
@@ -28,9 +27,11 @@ import {
|
||||
useFiestaDeliveries,
|
||||
useFiestaDeliverySummary,
|
||||
useFiestaRiders,
|
||||
useFiestaOrderDetails,
|
||||
} from '../services/fiestaQueries';
|
||||
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd } from '../services/fiestaApi';
|
||||
import { deliveryRowToOrder } from '../services/fiestaMappers';
|
||||
import AwaitingApi from './AwaitingApi';
|
||||
|
||||
interface OrdersDeliveriesViewProps {
|
||||
searchQuery?: string;
|
||||
@@ -43,7 +44,6 @@ interface DeliveryExecutive {
|
||||
name: string;
|
||||
phone: string;
|
||||
status: 'Active Duty' | 'Idle' | 'Offline';
|
||||
rating: number;
|
||||
completedToday: number;
|
||||
currentZone: string;
|
||||
avatar: string;
|
||||
@@ -61,7 +61,6 @@ function riderRowToExecutive(row: Record<string, unknown>, idx: number): Deliver
|
||||
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],
|
||||
@@ -141,76 +140,20 @@ export default function OrdersDeliveriesView({ searchQuery = '', isCoimbatoreVie
|
||||
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,
|
||||
// Live line-item details for the currently selected order. The deliveries board
|
||||
// only carries an itemCount; the actual basket lines come from this endpoint.
|
||||
const orderDetailsQ = useFiestaOrderDetails(selectedOrder?.id ?? null);
|
||||
const orderItems = (orderDetailsQ.data ?? []).map((row) => {
|
||||
const quantity = fnum(row.quantity) || fnum(row.qty);
|
||||
const price = fnum(row.price) || fnum(row.unitprice);
|
||||
const lineTotal = fnum(row.amount) || price * quantity;
|
||||
return {
|
||||
name: fstr(row.productname) || fstr(row.itemname) || 'Item',
|
||||
quantity,
|
||||
price,
|
||||
lineTotal,
|
||||
};
|
||||
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">
|
||||
@@ -348,10 +291,10 @@ export default function OrdersDeliveriesView({ searchQuery = '', isCoimbatoreVie
|
||||
<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="lg:col-span-2 flex">
|
||||
<div className="bg-white border border-[#e2e8f0] rounded-xl shadow-sm overflow-hidden flex flex-col h-full w-full min-h-[32rem]">
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div className="p-md border-b border-[#e2e8f0] bg-[#f8fafc] flex flex-col gap-md shrink-0">
|
||||
<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]">
|
||||
@@ -359,14 +302,6 @@ export default function OrdersDeliveriesView({ searchQuery = '', isCoimbatoreVie
|
||||
</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">
|
||||
@@ -401,11 +336,11 @@ export default function OrdersDeliveriesView({ searchQuery = '', isCoimbatoreVie
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order item rows */}
|
||||
<div className="divide-y divide-[#f1f5f9] max-h-[480px] overflow-y-auto">
|
||||
{/* Order item rows — flex-fills the column so the feed matches the Order Details card height */}
|
||||
<div className="divide-y divide-[#f1f5f9] flex-1 min-h-0 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.
|
||||
No orders matching status filter found. Try another query or adjust the date range.
|
||||
</div>
|
||||
) : (
|
||||
filteredOrdersList.map(order => (
|
||||
@@ -448,41 +383,6 @@ export default function OrdersDeliveriesView({ searchQuery = '', isCoimbatoreVie
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{/* Right column — Order Details, shown parallel to the orders feed */}
|
||||
@@ -513,19 +413,24 @@ export default function OrdersDeliveriesView({ searchQuery = '', isCoimbatoreVie
|
||||
<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 && (
|
||||
{orderDetailsQ.isLoading && (
|
||||
<div className="py-2 flex items-center gap-1.5 text-[10px] text-zinc-400 font-medium">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-zinc-300 animate-pulse" /> Loading order line items…
|
||||
</div>
|
||||
)}
|
||||
{!orderDetailsQ.isLoading && orderItems.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) => (
|
||||
{orderItems.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>
|
||||
<span className="font-bold font-mono text-zinc-700">₹{item.lineTotal}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="pt-2 flex justify-between items-center font-bold text-sm text-[#581c87] border-t border-dashed border-[#e2e8f0]">
|
||||
@@ -535,167 +440,13 @@ export default function OrdersDeliveriesView({ searchQuery = '', isCoimbatoreVie
|
||||
</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 */}
|
||||
{/* Live GPS route tracker — no rider-telemetry/GPS API yet */}
|
||||
{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>
|
||||
<AwaitingApi label="Live rider GPS & ETA" api="[R9]" compact />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} 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';
|
||||
import AwaitingApi from './AwaitingApi';
|
||||
|
||||
interface ReportsViewProps {
|
||||
searchQuery: string;
|
||||
@@ -49,7 +50,6 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
const [selectedCell, setSelectedCell] = useState<{ day: string; hour: string; val: number } | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [chartMetric, setChartMetric] = useState<'orders' | 'revenue' | 'cancelled' | 'skus'>('orders');
|
||||
const [hoveredPoint, setHoveredPoint] = useState<number | null>(null);
|
||||
const [expandedProductId, setExpandedProductId] = useState<string | null>(null);
|
||||
const [exportingFormat, setExportingFormat] = useState<'PDF' | 'CSV' | null>(null);
|
||||
const [exportProgress, setExportProgress] = useState(0);
|
||||
@@ -78,11 +78,17 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
|
||||
// ── 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 todate = ymd(today);
|
||||
|
||||
// Previous equal-length window (same number of days immediately before the
|
||||
// current YTD window) so we can derive a REAL orders/cancelled delta.
|
||||
const periodDays = Math.round((today.getTime() - yearStart.getTime()) / 86400000);
|
||||
const prevEnd = new Date(yearStart.getTime() - 86400000);
|
||||
const prevStart = new Date(prevEnd.getTime() - periodDays * 86400000);
|
||||
|
||||
const summaryQ = useFiestaOrderSummary(FIESTA_TENANT_ID, ymd(yearStart), todate);
|
||||
const prevSummaryQ = useFiestaOrderSummary(FIESTA_TENANT_ID, ymd(prevStart), ymd(prevEnd));
|
||||
const locSummaryQ = useFiestaLocationSummary(FIESTA_TENANT_ID);
|
||||
const insightQ = useFiestaOrderInsight(FIESTA_TENANT_ID);
|
||||
const stockQ = useFiestaStockStatement({
|
||||
@@ -94,94 +100,17 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
});
|
||||
|
||||
const s = summaryQ.data;
|
||||
const prevS = prevSummaryQ.data;
|
||||
const activeSkus = (stockQ.data ?? []).length;
|
||||
|
||||
// Base YTD data pool
|
||||
const CHART_DATA_YTD = [
|
||||
{ label: 'Jan', orders: 240, revenue: 78000, cancelled: 15, skus: 120 },
|
||||
{ label: 'Feb', orders: 310, revenue: 98000, cancelled: 10, skus: 125 },
|
||||
{ label: 'Mar', orders: 290, revenue: 89000, cancelled: 8, skus: 128 },
|
||||
{ label: 'Apr', orders: 380, revenue: 120000, cancelled: 12, skus: 135 },
|
||||
{ label: 'May', orders: 420, revenue: 145000, cancelled: 5, skus: 138 },
|
||||
{ label: 'Jun', orders: 510, revenue: 175000, cancelled: 9, skus: 140 },
|
||||
{ label: 'Jul', orders: 480, revenue: 162000, cancelled: 4, skus: 142 },
|
||||
{ label: 'Aug', orders: 560, revenue: 189000, cancelled: 3, skus: 145 },
|
||||
{ label: 'Sep', orders: 630, revenue: 215000, cancelled: 6, skus: 150 },
|
||||
{ label: 'Oct', orders: 710, revenue: 248000, cancelled: 8, skus: 152 },
|
||||
{ label: 'Nov', orders: 790, revenue: 275000, cancelled: 5, skus: 155 },
|
||||
{ label: 'Dec', orders: 920, revenue: 320000, cancelled: 2, skus: 158 },
|
||||
];
|
||||
|
||||
// Dynamic coordinates builder based on selected region and timeframe
|
||||
const getDynamicChartData = () => {
|
||||
let rawData = [...CHART_DATA_YTD];
|
||||
|
||||
if (selectedTimeframe === 'This Month') {
|
||||
rawData = [
|
||||
{ label: '02 Jun', orders: 15, revenue: 5200, cancelled: 1, skus: 145 },
|
||||
{ label: '04 Jun', orders: 18, revenue: 6100, cancelled: 0, skus: 145 },
|
||||
{ label: '06 Jun', orders: 12, revenue: 4300, cancelled: 2, skus: 145 },
|
||||
{ label: '08 Jun', orders: 22, revenue: 7800, cancelled: 1, skus: 146 },
|
||||
{ label: '10 Jun', orders: 25, revenue: 8900, cancelled: 3, skus: 146 },
|
||||
{ label: '12 Jun', orders: 28, revenue: 9900, cancelled: 1, skus: 147 },
|
||||
{ label: '14 Jun', orders: 24, revenue: 8400, cancelled: 0, skus: 147 },
|
||||
{ label: '16 Jun', orders: 30, revenue: 10500, cancelled: 2, skus: 148 },
|
||||
{ label: '18 Jun', orders: 35, revenue: 12200, cancelled: 1, skus: 148 },
|
||||
{ label: '20 Jun', orders: 32, revenue: 11100, cancelled: 0, skus: 149 },
|
||||
{ label: '22 Jun', orders: 38, revenue: 13300, cancelled: 4, skus: 149 },
|
||||
{ label: '24 Jun', orders: 42, revenue: 14800, cancelled: 2, skus: 150 },
|
||||
{ label: '26 Jun', orders: 45, revenue: 15800, cancelled: 1, skus: 150 },
|
||||
{ label: '28 Jun', orders: 40, revenue: 13900, cancelled: 0, skus: 151 },
|
||||
{ label: '30 Jun', orders: 50, revenue: 17500, cancelled: 1, skus: 151 },
|
||||
];
|
||||
} else if (selectedTimeframe === 'Last 12 Months') {
|
||||
rawData = [
|
||||
{ label: 'Jul 25', orders: 480, revenue: 162000, cancelled: 4, skus: 142 },
|
||||
{ label: 'Aug 25', orders: 560, revenue: 189000, cancelled: 3, skus: 145 },
|
||||
{ label: 'Sep 25', orders: 630, revenue: 215000, cancelled: 6, skus: 150 },
|
||||
{ label: 'Oct 25', orders: 710, revenue: 248000, cancelled: 8, skus: 152 },
|
||||
{ label: 'Nov 25', orders: 790, revenue: 275000, cancelled: 5, skus: 155 },
|
||||
{ label: 'Dec 25', orders: 920, revenue: 320000, cancelled: 2, skus: 158 },
|
||||
{ label: 'Jan 26', orders: 840, revenue: 290000, cancelled: 12, skus: 160 },
|
||||
{ label: 'Feb 26', orders: 890, revenue: 310000, cancelled: 8, skus: 162 },
|
||||
{ label: 'Mar 26', orders: 950, revenue: 330000, cancelled: 14, skus: 165 },
|
||||
{ label: 'Apr 26', orders: 1020, revenue: 355000, cancelled: 10, skus: 168 },
|
||||
{ label: 'May 26', orders: 1100, revenue: 385000, cancelled: 7, skus: 170 },
|
||||
{ label: 'Jun 26', orders: 1250, revenue: 435000, cancelled: 5, skus: 172 },
|
||||
];
|
||||
} else if (selectedTimeframe === 'All Time') {
|
||||
rawData = [
|
||||
{ label: '2022', orders: 2500, revenue: 850000, cancelled: 85, skus: 90 },
|
||||
{ label: '2023', orders: 4800, revenue: 1650000, cancelled: 120, skus: 120 },
|
||||
{ label: '2024', orders: 7200, revenue: 2500000, cancelled: 190, skus: 140 },
|
||||
{ label: '2025', orders: 9800, revenue: 3400000, cancelled: 210, skus: 160 },
|
||||
{ label: '2026 (Est)', orders: 12500, revenue: 4350000, cancelled: 150, skus: 172 },
|
||||
];
|
||||
}
|
||||
|
||||
// Scale values depending on region selected
|
||||
if (selectedRegion !== 'all') {
|
||||
const rScale = getRegionScale();
|
||||
return rawData.map(d => ({
|
||||
...d,
|
||||
orders: Math.round(d.orders * rScale),
|
||||
revenue: Math.round(d.revenue * (rScale * 1.05)),
|
||||
cancelled: Math.round(d.cancelled * (selectedRegion === 'coimbatore' ? 0.35 : selectedRegion === 'chennai' ? 0.50 : 0.65)),
|
||||
skus: Math.round(d.skus * (selectedRegion === 'coimbatore' ? 0.85 : selectedRegion === 'chennai' ? 0.90 : 0.95))
|
||||
}));
|
||||
}
|
||||
|
||||
return rawData;
|
||||
// Real period-over-period % change (null when we can't compute it yet).
|
||||
const pctChange = (current: number, previous: number): number | null => {
|
||||
if (previous <= 0) return null;
|
||||
return ((current - previous) / previous) * 100;
|
||||
};
|
||||
|
||||
const getRegionScale = () => {
|
||||
if (selectedRegion === 'coimbatore') return 0.42;
|
||||
if (selectedRegion === 'chennai') return 0.60;
|
||||
if (selectedRegion === 'bangalore') return 0.75;
|
||||
return 1.0;
|
||||
};
|
||||
|
||||
const currentChartData = getDynamicChartData();
|
||||
const ordersDelta = s && prevS ? pctChange(s.total, prevS.total) : null;
|
||||
const cancelledDelta = s && prevS ? pctChange(s.cancelled, prevS.cancelled) : null;
|
||||
const fmtDelta = (d: number) => `${d >= 0 ? '+' : ''}${d.toFixed(1)}%`;
|
||||
|
||||
// Dynamic sparkline generator helper
|
||||
const getSparkPath = (values: number[], width: number, height: number) => {
|
||||
@@ -195,46 +124,6 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
}).join(' ');
|
||||
};
|
||||
|
||||
// Simple cubic bezier curve generator for SVG path
|
||||
const getBezierPath = (pts: Array<{ x: number; y: number }>) => {
|
||||
if (pts.length === 0) return '';
|
||||
let d = `M ${pts[0].x} ${pts[0].y}`;
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const p0 = pts[i];
|
||||
const p1 = pts[i + 1];
|
||||
const cpX1 = p0.x + (p1.x - p0.x) / 3;
|
||||
const cpY1 = p0.y;
|
||||
const cpX2 = p0.x + 2 * (p1.x - p0.x) / 3;
|
||||
const cpY2 = p1.y;
|
||||
d += ` C ${cpX1} ${cpY1}, ${cpX2} ${cpY2}, ${p1.x} ${p1.y}`;
|
||||
}
|
||||
return d;
|
||||
};
|
||||
|
||||
// Dynamic SVG path calculations for the primary trend chart
|
||||
const paddingX = 40;
|
||||
const paddingY = 20;
|
||||
const chartWidth = 920;
|
||||
const chartHeight = 220;
|
||||
|
||||
const chartMaxVal = chartMetric === 'orders'
|
||||
? Math.max(...currentChartData.map(d => d.orders)) * 1.1
|
||||
: chartMetric === 'revenue'
|
||||
? Math.max(...currentChartData.map(d => d.revenue)) * 1.1
|
||||
: chartMetric === 'cancelled'
|
||||
? Math.max(...currentChartData.map(d => d.cancelled)) * 1.1
|
||||
: Math.max(...currentChartData.map(d => d.skus)) * 1.1;
|
||||
|
||||
const points = currentChartData.map((d, index) => {
|
||||
const val = d[chartMetric] as number;
|
||||
const x = paddingX + (index / (currentChartData.length - 1)) * (chartWidth - 2 * paddingX);
|
||||
const y = chartHeight - paddingY - (val / chartMaxVal) * (chartHeight - 2 * paddingY);
|
||||
return { x, y, label: d.label, val };
|
||||
});
|
||||
|
||||
const linePath = getBezierPath(points);
|
||||
const areaPath = points.length ? `${linePath} L ${points[points.length - 1].x} ${chartHeight - paddingY} L ${points[0].x} ${chartHeight - paddingY} Z` : '';
|
||||
|
||||
// Tab thematic config
|
||||
const getChartColors = () => {
|
||||
switch (chartMetric) {
|
||||
@@ -274,57 +163,61 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
};
|
||||
const theme = getChartColors();
|
||||
|
||||
// Region specific calculations for KPIs
|
||||
const scale = getRegionScale();
|
||||
const scaleCancelled = selectedRegion === 'coimbatore' ? 0.35 : selectedRegion === 'chennai' ? 0.50 : selectedRegion === 'bangalore' ? 0.65 : 1.0;
|
||||
const scaleSkus = selectedRegion === 'coimbatore' ? 0.85 : selectedRegion === 'chennai' ? 0.90 : selectedRegion === 'bangalore' ? 0.95 : 1.0;
|
||||
// Live KPI values (tenant-wide; region scaling removed — no per-region API).
|
||||
const totalOrdersVal = s?.total ?? 0;
|
||||
const deliveredVal = s?.delivered ?? 0;
|
||||
const cancelledVal = s?.cancelled ?? 0;
|
||||
const activeSkusVal = activeSkus;
|
||||
|
||||
const totalOrdersVal = Math.round((s?.total ?? 0) * scale);
|
||||
const deliveredVal = Math.round((s?.delivered ?? 0) * scale);
|
||||
const cancelledVal = Math.round((s?.cancelled ?? 0) * scaleCancelled);
|
||||
const activeSkusVal = Math.round(activeSkus * scaleSkus);
|
||||
|
||||
// KPI Row Configuration
|
||||
// KPI Row Configuration. `awaiting` cards have no live value (rendered via
|
||||
// AwaitingApi). `trend` is only set where a REAL delta could be derived.
|
||||
const reportsKPIs = [
|
||||
{
|
||||
id: 'orders' as const,
|
||||
title: 'Orders',
|
||||
value: totalOrdersVal.toLocaleString('en-IN'),
|
||||
trend: `+12.5%`,
|
||||
trend: ordersDelta !== null ? fmtDelta(ordersDelta) : null,
|
||||
status: `${deliveredVal.toLocaleString('en-IN')} filled`,
|
||||
isPositive: true,
|
||||
isPositive: ordersDelta === null ? true : ordersDelta >= 0,
|
||||
spark: [30, 45, 35, 60, 55, 70, 65, 80],
|
||||
color: 'indigo'
|
||||
color: 'indigo',
|
||||
awaiting: false,
|
||||
},
|
||||
{
|
||||
// Revenue: no revenue API ([R1]) — render AwaitingApi instead of a value.
|
||||
id: 'revenue' as const,
|
||||
title: 'Revenue',
|
||||
value: `₹${(deliveredVal * 355).toLocaleString('en-IN')}`,
|
||||
trend: `+14.8%`,
|
||||
status: `Growth steady`,
|
||||
value: '',
|
||||
trend: null,
|
||||
status: '',
|
||||
isPositive: true,
|
||||
spark: [20, 30, 25, 45, 40, 55, 50, 68],
|
||||
color: 'emerald'
|
||||
color: 'emerald',
|
||||
awaiting: true,
|
||||
},
|
||||
{
|
||||
id: 'cancelled' as const,
|
||||
title: 'Cancelled',
|
||||
value: cancelledVal.toLocaleString('en-IN'),
|
||||
trend: `-1.2%`,
|
||||
status: `${Math.round((s?.created ?? 0) * scaleCancelled)} active`,
|
||||
isPositive: false,
|
||||
// Lower cancellations is good, so a negative delta is "positive".
|
||||
trend: cancelledDelta !== null ? fmtDelta(cancelledDelta) : null,
|
||||
status: `${(s?.created ?? 0).toLocaleString('en-IN')} active`,
|
||||
isPositive: cancelledDelta === null ? false : cancelledDelta <= 0,
|
||||
spark: [15, 10, 8, 12, 5, 9, 4, 3],
|
||||
color: 'rose'
|
||||
color: 'rose',
|
||||
awaiting: false,
|
||||
},
|
||||
{
|
||||
id: 'skus' as const,
|
||||
title: 'Active SKUs',
|
||||
value: activeSkusVal.toLocaleString('en-IN'),
|
||||
trend: `+8.4%`,
|
||||
// SKU delta value itself was fabricated — show no trend chip.
|
||||
trend: null,
|
||||
status: `All verified`,
|
||||
isPositive: true,
|
||||
spark: [50, 50, 55, 60, 60, 68, 70, 72],
|
||||
color: 'sky'
|
||||
color: 'sky',
|
||||
awaiting: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -337,25 +230,15 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
const getFilteredLocations = () => {
|
||||
const rawLocations = [...(locSummaryQ.data ?? [])];
|
||||
|
||||
// Only Coimbatore can be filtered from live data; Chennai/Bangalore have no
|
||||
// live tenant locations (their stub data was removed). Selecting them yields
|
||||
// an empty list rather than fabricated hubs.
|
||||
if (selectedRegion === 'coimbatore') {
|
||||
return rawLocations.filter(r => isCoimbatoreNode(r.locationname || ''));
|
||||
}
|
||||
|
||||
if (selectedRegion === 'chennai') {
|
||||
return [
|
||||
{ locationid: 2001, locationname: 'Chennai Adyar Hub', total: 420, delivered: 405, cancelled: 15 },
|
||||
{ locationid: 2002, locationname: 'Chennai T-Nagar Outlet', total: 310, delivered: 290, cancelled: 20 },
|
||||
{ locationid: 2003, locationname: 'Chennai Velachery Super', total: 290, delivered: 285, cancelled: 5 },
|
||||
{ locationid: 2004, locationname: 'Chennai OMR Express', total: 180, delivered: 172, cancelled: 8 },
|
||||
] as any[];
|
||||
}
|
||||
|
||||
if (selectedRegion === 'bangalore') {
|
||||
return [
|
||||
{ locationid: 3001, locationname: 'Bangalore Indiranagar Hub', total: 580, delivered: 560, cancelled: 20 },
|
||||
{ locationid: 3002, locationname: 'Bangalore Koramangala Store', total: 410, delivered: 395, cancelled: 15 },
|
||||
{ locationid: 3003, locationname: 'Bangalore HSR Layout Express', total: 320, delivered: 312, cancelled: 8 },
|
||||
] as any[];
|
||||
if (selectedRegion === 'chennai' || selectedRegion === 'bangalore') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return rawLocations;
|
||||
@@ -372,32 +255,22 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
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`,
|
||||
// Live order count drives the ranking/bar. No per-node revenue API yet, so the
|
||||
// label shows the real order count (not fabricated rupees) — revenue lands with [R1].
|
||||
revenue: `${r.total.toLocaleString()} ord`,
|
||||
}));
|
||||
})();
|
||||
const currentLeaderboard = leaderboard;
|
||||
|
||||
// Monthly order distribution per outlet
|
||||
// Monthly order distribution per outlet — live only (useFiestaOrderInsight
|
||||
// already covers all the tenant's locations). The Chennai/Bangalore stub rows
|
||||
// were removed; selecting those regions filters the live rows to none.
|
||||
const insightRows = (() => {
|
||||
if (selectedRegion === 'chennai') {
|
||||
return [
|
||||
{ name: 'Chennai Adyar Hub', months: { jan: 30, feb: 35, mar: 40, apr: 45, may: 50, jun: 55, jul: 60, Aug: 65, Sep: 70, Oct: 75, Nov: 80, Dece: 85 } },
|
||||
{ name: 'Chennai T-Nagar Outlet', months: { jan: 25, feb: 28, mar: 30, apr: 35, may: 38, jun: 42, jul: 45, Aug: 48, Sep: 52, Oct: 55, Nov: 60, Dece: 65 } },
|
||||
{ name: 'Chennai Velachery Super', months: { jan: 20, feb: 22, mar: 25, apr: 28, may: 30, jun: 35, jul: 38, Aug: 42, Sep: 45, Oct: 48, Nov: 52, Dece: 55 } },
|
||||
{ name: 'Chennai OMR Express', months: { jan: 15, feb: 18, mar: 20, apr: 22, may: 25, jun: 28, jul: 30, Aug: 32, Sep: 35, Oct: 38, Nov: 40, Dece: 45 } },
|
||||
];
|
||||
}
|
||||
if (selectedRegion === 'bangalore') {
|
||||
return [
|
||||
{ name: 'Bangalore Indiranagar Hub', months: { jan: 40, feb: 45, mar: 50, apr: 55, may: 60, jun: 65, jul: 70, Aug: 75, Sep: 80, Oct: 85, Nov: 90, Dece: 95 } },
|
||||
{ name: 'Bangalore Koramangala Store', months: { jan: 30, feb: 32, mar: 35, apr: 38, may: 42, jun: 45, jul: 48, Aug: 52, Sep: 55, Oct: 60, Nov: 65, Dece: 70 } },
|
||||
{ name: 'Bangalore HSR Layout Express', months: { jan: 20, feb: 24, mar: 26, apr: 28, may: 32, jun: 35, jul: 38, Aug: 40, Sep: 44, Oct: 48, Nov: 52, Dece: 55 } },
|
||||
];
|
||||
}
|
||||
|
||||
let rows = (insightQ.data ?? []);
|
||||
if (selectedRegion === 'coimbatore') {
|
||||
rows = rows.filter(r => isCoimbatoreNode(fstr(r.locationname)));
|
||||
} else if (selectedRegion === 'chennai' || selectedRegion === 'bangalore') {
|
||||
rows = [];
|
||||
}
|
||||
return rows.map((r) => ({
|
||||
name: fstr(r.locationname) || `Location ${fstr(r.locationid)}`,
|
||||
@@ -465,7 +338,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
<div className="absolute top-40 right-1/4 w-[28rem] h-[28rem] bg-indigo-400/5 rounded-full blur-[140px] pointer-events-none -z-10 animate-pulse" style={{ animationDuration: '8s' }} />
|
||||
|
||||
{/* ── Immersive Analytics Banner (With Data Cover Image & Slate Gradient Overlay) ── */}
|
||||
<div className="relative rounded-2xl p-6 md:p-8 text-white shadow-xl border border-purple-500/20 mb-8 animate-in fade-in duration-300 z-35">
|
||||
<div className="relative rounded-2xl p-6 md:p-8 text-white shadow-xl border border-purple-500/20 mb-8 animate-in fade-in duration-300 z-40">
|
||||
{/* Cover Image Background & Decor (wrapped in overflow-hidden to keep rounded corner clip, while allowing dropdown overflow) */}
|
||||
<div className="absolute inset-0 z-0 overflow-hidden rounded-2xl">
|
||||
<img
|
||||
@@ -481,7 +354,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
</div>
|
||||
|
||||
{/* Content Row */}
|
||||
<div className="relative z-10 flex flex-col md:flex-row md:items-center justify-between gap-lg">
|
||||
<div className="relative z-20 flex flex-col md:flex-row md:items-center justify-between gap-lg">
|
||||
<div>
|
||||
<h1 className="font-sans font-bold text-2xl tracking-tight text-white flex items-center gap-2">
|
||||
Business Intelligence Center
|
||||
@@ -634,13 +507,13 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<h3 className="text-xl font-extrabold tracking-tight font-mono">
|
||||
{currentChartData.reduce((acc, curr) => acc + curr.orders, 0).toLocaleString('en-IN')}
|
||||
{totalOrdersVal.toLocaleString('en-IN')}
|
||||
</h3>
|
||||
<p className="text-[10px] text-slate-400 font-semibold mt-1">Segment Volume</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 4: Total Segment Revenue */}
|
||||
{/* Card 4: Gross Revenue — no revenue API ([R1]) */}
|
||||
<div className="bg-slate-900/40 backdrop-blur-sm rounded-xl p-4 border border-slate-800/80 hover:border-purple-500/30 transition-all group">
|
||||
<div className="flex justify-between items-start">
|
||||
<span className="text-[10px] text-slate-400 uppercase tracking-widest font-bold">Gross Revenue</span>
|
||||
@@ -649,10 +522,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<h3 className="text-xl font-extrabold tracking-tight font-mono">
|
||||
₹{currentChartData.reduce((acc, curr) => acc + curr.revenue, 0).toLocaleString('en-IN')}
|
||||
</h3>
|
||||
<p className="text-[10px] text-slate-400 font-semibold mt-1">Estimated Value</p>
|
||||
<AwaitingApi label="Gross Revenue" api="[R1]" compact />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -726,16 +596,24 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
</div>
|
||||
|
||||
{/* Main Metric Value and Trend Badge */}
|
||||
<div className="mt-3 flex items-baseline gap-2">
|
||||
<div className="font-sans font-black text-slate-900 text-3xl tracking-tight leading-none">
|
||||
{kpi.value}
|
||||
{kpi.awaiting ? (
|
||||
<div className="mt-3">
|
||||
<AwaitingApi label="Revenue" api="[R1]" compact />
|
||||
</div>
|
||||
<span className={`text-[9px] font-bold px-2 py-0.5 rounded-full flex items-center gap-0.5 leading-none h-4 ${kpi.isPositive ? 'bg-emerald-50 text-emerald-600 border border-emerald-100/50' : 'bg-rose-50 text-rose-600 border border-rose-100/50'
|
||||
}`}>
|
||||
{kpi.isPositive ? '▲' : '▼'}
|
||||
{kpi.trend}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 flex items-baseline gap-2">
|
||||
<div className="font-sans font-black text-slate-900 text-3xl tracking-tight leading-none">
|
||||
{kpi.value}
|
||||
</div>
|
||||
{kpi.trend && (
|
||||
<span className={`text-[9px] font-bold px-2 py-0.5 rounded-full flex items-center gap-0.5 leading-none h-4 ${kpi.isPositive ? 'bg-emerald-50 text-emerald-600 border border-emerald-100/50' : 'bg-rose-50 text-rose-600 border border-rose-100/50'
|
||||
}`}>
|
||||
{kpi.isPositive ? '▲' : '▼'}
|
||||
{kpi.trend}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom Sparkline & Subtext segment */}
|
||||
<div className="flex items-center justify-between mt-auto pt-3 w-full border-t border-[#f1f5f9]">
|
||||
@@ -786,158 +664,11 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SVG Custom Graph Area */}
|
||||
<div className="relative h-64 select-none w-full">
|
||||
<svg className="w-full h-full overflow-visible" viewBox={`0 0 ${chartWidth} ${chartHeight}`}>
|
||||
<defs>
|
||||
{/* Indigo Gradients */}
|
||||
<linearGradient id="chart-indigo-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#818cf8" />
|
||||
<stop offset="100%" stopColor="#4f46e5" />
|
||||
</linearGradient>
|
||||
<linearGradient id="chart-indigo-area" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor="#6366f1" stopOpacity="0.15" />
|
||||
<stop offset="100%" stopColor="#6366f1" stopOpacity="0.00" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Emerald Gradients */}
|
||||
<linearGradient id="chart-emerald-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#34d399" />
|
||||
<stop offset="100%" stopColor="#059669" />
|
||||
</linearGradient>
|
||||
<linearGradient id="chart-emerald-area" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor="#10b981" stopOpacity="0.15" />
|
||||
<stop offset="100%" stopColor="#10b981" stopOpacity="0.00" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Rose Gradients */}
|
||||
<linearGradient id="chart-rose-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#fb7185" />
|
||||
<stop offset="100%" stopColor="#e11d48" />
|
||||
</linearGradient>
|
||||
<linearGradient id="chart-rose-area" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor="#f43f5e" stopOpacity="0.15" />
|
||||
<stop offset="100%" stopColor="#f43f5e" stopOpacity="0.00" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Sky Gradients */}
|
||||
<linearGradient id="chart-sky-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#38bdf8" />
|
||||
<stop offset="100%" stopColor="#0284c7" />
|
||||
</linearGradient>
|
||||
<linearGradient id="chart-sky-area" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor="#0ea5e9" stopOpacity="0.15" />
|
||||
<stop offset="100%" stopColor="#0ea5e9" stopOpacity="0.00" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Grid Lines */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((ratio, idx) => {
|
||||
const y = paddingY + ratio * (chartHeight - 2 * paddingY);
|
||||
return (
|
||||
<line
|
||||
key={idx}
|
||||
x1={paddingX}
|
||||
y1={y}
|
||||
x2={chartWidth - paddingX}
|
||||
y2={y}
|
||||
stroke="#f1f5f9"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Area Fill */}
|
||||
{areaPath && (
|
||||
<path d={areaPath} fill={theme.fill} className="transition-all duration-550 ease-out" />
|
||||
)}
|
||||
|
||||
{/* Line Path */}
|
||||
{linePath && (
|
||||
<path
|
||||
d={linePath}
|
||||
fill="none"
|
||||
stroke={theme.stroke}
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-550 ease-out"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Hover Indicator Vertical Line */}
|
||||
{hoveredPoint !== null && (
|
||||
<line
|
||||
x1={points[hoveredPoint].x}
|
||||
y1={paddingY}
|
||||
x2={points[hoveredPoint].x}
|
||||
y2={chartHeight - paddingY}
|
||||
stroke={theme.activeLine}
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray="3 3"
|
||||
className="transition-all duration-150"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Chart Points & Interactive Hover Areas */}
|
||||
{points.map((p, idx) => {
|
||||
const isHovered = hoveredPoint === idx;
|
||||
return (
|
||||
<g key={idx}>
|
||||
<circle
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={isHovered ? 6 : 4}
|
||||
fill={isHovered ? theme.hoverCircle : theme.pointFill}
|
||||
stroke="#ffffff"
|
||||
strokeWidth="2"
|
||||
className="transition-all duration-150"
|
||||
/>
|
||||
<circle
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r="20"
|
||||
fill="transparent"
|
||||
className="cursor-pointer"
|
||||
onMouseEnter={() => setHoveredPoint(idx)}
|
||||
onMouseLeave={() => setHoveredPoint(null)}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Hover Tooltip Overlay */}
|
||||
{hoveredPoint !== null && (
|
||||
<div
|
||||
className="absolute bg-zinc-950/95 border border-zinc-800 text-white rounded-2xl p-3 shadow-2xl font-sans text-xs z-10 pointer-events-none transition-all animate-in zoom-in-95 duration-150 flex flex-col gap-1 w-44 backdrop-blur-md"
|
||||
style={{
|
||||
left: `${(points[hoveredPoint].x / chartWidth) * 100}%`,
|
||||
top: `${(points[hoveredPoint].y / chartHeight) * 100 - 36}%`,
|
||||
transform: 'translateX(-50%)'
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-between items-center border-b border-zinc-800 pb-1 mb-1">
|
||||
<span className="font-bold text-zinc-400">{currentChartData[hoveredPoint].label}</span>
|
||||
<span className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: theme.activeLine }} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Metrics Focus</span>
|
||||
<span className="font-extrabold font-mono text-base" style={{ color: theme.activeLine }}>
|
||||
{chartMetric === 'orders' ? `${points[hoveredPoint].val} Orders` :
|
||||
chartMetric === 'revenue' ? `₹${points[hoveredPoint].val.toLocaleString('en-IN')}` :
|
||||
chartMetric === 'cancelled' ? `${points[hoveredPoint].val} Cancelled` :
|
||||
`${points[hoveredPoint].val} SKUs`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* X Axis Labels */}
|
||||
<div className="flex justify-between items-center text-[10px] font-bold text-zinc-400 uppercase font-mono px-xl border-t border-[#f1f5f9] pt-md mt-sm select-none">
|
||||
{currentChartData.map((d, index) => (
|
||||
<span key={index}>{d.label}</span>
|
||||
))}
|
||||
{/* Plotted Area — no time-series API ([R2]) for orders/revenue/skus.
|
||||
The metric tabs (KPI cards above) still switch the card title; the
|
||||
chart body itself shows the awaiting-backend placeholder. */}
|
||||
<div className="relative h-64 select-none w-full flex items-center justify-center">
|
||||
<AwaitingApi label="Orders & revenue time-series" api="[R2]" className="w-full h-full justify-center" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1204,89 +935,11 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
<tr className="bg-slate-50/20">
|
||||
<td colSpan={7} className="p-0 border-b border-[#e2e8f0]">
|
||||
<div className="px-lg py-md bg-gradient-to-r from-slate-50/50 to-purple-50/10 border-t border-[#e2e8f0] animate-in slide-in-from-top-2 duration-300">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-lg text-xs">
|
||||
|
||||
{/* Inventory Level Progress block */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider block">
|
||||
Stock Capacity Index
|
||||
</span>
|
||||
<div className="bg-white border border-[#e2e8f0] rounded-xl p-md shadow-sm">
|
||||
<div className="flex justify-between items-center mb-xs font-semibold">
|
||||
<span className="text-zinc-655">Current Balance</span>
|
||||
<span className={
|
||||
prod.stockStatus === 'Healthy' ? 'text-emerald-600' :
|
||||
prod.stockStatus === 'Low Stock' ? 'text-amber-600' : 'text-rose-600'
|
||||
}>
|
||||
{prod.stockStatus === 'Healthy' ? '142 Units (Optimal)' :
|
||||
prod.stockStatus === 'Low Stock' ? '42 Units (Low)' : '6 Units (Critical)'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-100 h-2.5 rounded-full overflow-hidden mt-1.5">
|
||||
<div className={`h-full rounded-full transition-all duration-500 ${prod.stockStatus === 'Healthy' ? 'bg-emerald-500 w-[85%]' :
|
||||
prod.stockStatus === 'Low Stock' ? 'bg-amber-500 w-[35%]' : 'bg-rose-500 w-[8%]'
|
||||
}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Distribution Locations */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider block">
|
||||
Hub Distribution Allocations
|
||||
</span>
|
||||
<div className="bg-white border border-[#e2e8f0] rounded-xl p-md shadow-sm space-y-2">
|
||||
<div className="flex justify-between text-[10px] font-semibold text-zinc-600">
|
||||
<span>Saravanampatti Hub</span>
|
||||
<span className="font-mono">{prod.stockStatus === 'Healthy' ? '85 units' : prod.stockStatus === 'Low Stock' ? '25 units' : '4 units'}</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-100 h-1.5 rounded-full overflow-hidden">
|
||||
<div className="bg-purple-500 h-full rounded-full w-[60%]" />
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] font-semibold text-zinc-600 mt-1">
|
||||
<span>RS Puram Hub</span>
|
||||
<span className="font-mono">{prod.stockStatus === 'Healthy' ? '57 units' : prod.stockStatus === 'Low Stock' ? '17 units' : '2 units'}</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-100 h-1.5 rounded-full overflow-hidden">
|
||||
<div className="bg-indigo-500 h-full rounded-full w-[40%]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Audit */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider block">
|
||||
Metadata & Barcode Identification
|
||||
</span>
|
||||
<div className="bg-white border border-[#e2e8f0] rounded-xl p-md shadow-sm flex items-center justify-between gap-md">
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
<span className="text-[8px] text-zinc-400 font-bold uppercase block leading-none mb-0.5">Warehouse Bin</span>
|
||||
<span className="font-mono font-bold text-zinc-750">BIN-C{prod.sku.replace(/\D/g, '').slice(-3) || '042'}</span>
|
||||
</div>
|
||||
<div className="pt-1">
|
||||
<span className="text-[8px] text-zinc-400 font-bold uppercase block leading-none mb-0.5">Last Audited</span>
|
||||
<span className="text-zinc-650 font-medium">{new Date().toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' })}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Monospace barcode simulation */}
|
||||
<div className="flex flex-col items-center shrink-0 select-none bg-zinc-50 p-2 rounded-lg border border-zinc-100">
|
||||
<div className="flex items-center gap-[1.5px] h-7 px-1">
|
||||
{[1, 3, 1, 2, 4, 1, 3, 2, 1, 2, 3, 1, 2, 4, 1, 2].map((w, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-zinc-805 h-full"
|
||||
style={{ width: `${w * 0.7}px` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[8px] font-mono text-zinc-400 mt-1 uppercase tracking-wider">{prod.sku}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{/* Per-product stock & location breakdown has no live
|
||||
API ([R3]); the previously fabricated unit counts,
|
||||
hub split, bin code, audit date and barcode are
|
||||
replaced with the awaiting-backend placeholder. */}
|
||||
<AwaitingApi label="Per-product stock & location detail" api="[R3]" compact />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -14,14 +14,13 @@ import {
|
||||
MapPin,
|
||||
Phone,
|
||||
Mail,
|
||||
Check,
|
||||
RotateCcw,
|
||||
CheckCircle2,
|
||||
Plus
|
||||
} from 'lucide-react';
|
||||
import { useFiestaAllTenants, useFiestaTenantLocations } from '../services/fiestaQueries';
|
||||
import { useAppRoles } from '../services/queries';
|
||||
import { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../services/fiestaApi';
|
||||
import UsersPanel from './UsersPanel';
|
||||
import AwaitingApi from './AwaitingApi';
|
||||
|
||||
interface SettingsViewProps {
|
||||
tenantId?: number;
|
||||
@@ -55,8 +54,6 @@ interface MerchantSettings {
|
||||
sandboxMode: boolean;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'merchant-settings-v1';
|
||||
|
||||
const DEFAULTS: MerchantSettings = {
|
||||
contactEmail: '',
|
||||
contactPhone: '',
|
||||
@@ -78,28 +75,6 @@ const DEFAULTS: MerchantSettings = {
|
||||
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 };
|
||||
}
|
||||
|
||||
// Localized fallback dataset to replace generic Faker test data with realistic Coimbatore outlets
|
||||
const LOCAL_OUTLETS_DATA = [
|
||||
{ name: 'Ragul Stores - Gandhipuram Hub', suburb: 'Gandhipuram', city: 'Coimbatore', postcode: '641018', radius: 4500, mins: 30 },
|
||||
{ name: 'Ragul Stores - Peelamedu Hub', suburb: 'Peelamedu', city: 'Coimbatore', postcode: '641004', radius: 3500, mins: 25 },
|
||||
{ name: 'Ragul Stores - RS Puram Hub', suburb: 'RS Puram', city: 'Coimbatore', postcode: '641002', radius: 5000, mins: 35 },
|
||||
{ name: 'Ragul Stores - Saravanampatti Outlet', suburb: 'Saravanampatti', city: 'Coimbatore', postcode: '641035', radius: 6000, mins: 40 },
|
||||
{ name: 'Ragul Stores - Singanallur Outlet', suburb: 'Singanallur', city: 'Coimbatore', postcode: '641005', radius: 4000, mins: 30 },
|
||||
{ name: 'Ragul Stores - Vadavalli Hub', suburb: 'Vadavalli', city: 'Coimbatore', postcode: '641046', radius: 3000, mins: 20 },
|
||||
{ name: 'Ragul Stores - Ramanathapuram Hub', suburb: 'Ramanathapuram', city: 'Coimbatore', postcode: '641045', radius: 4500, mins: 30 },
|
||||
{ name: 'Ragul Stores - Town Hall Outlet', suburb: 'Town Hall', city: 'Coimbatore', postcode: '641001', radius: 3500, mins: 25 },
|
||||
];
|
||||
|
||||
const formatFriendlyTime = (timeStr: string) => {
|
||||
try {
|
||||
if (timeStr.includes('T')) {
|
||||
@@ -127,15 +102,6 @@ const formatFriendlyTime = (timeStr: string) => {
|
||||
};
|
||||
|
||||
/// ── Small presentational helpers ────────────────────────────────────────────
|
||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: () => void }) {
|
||||
return (
|
||||
<label className="relative inline-flex items-center cursor-pointer shrink-0 select-none group">
|
||||
<input type="checkbox" checked={checked} onChange={onChange} className="sr-only peer" />
|
||||
<div className="w-11 h-6 bg-slate-200 peer-focus:outline-none rounded-full transition-all duration-300 peer-checked:bg-purple-650 after:content-[''] after:absolute after:top-[3.5px] after:left-[4px] after:bg-white after:rounded-full after:h-4.5 after:w-4.5 after:transition-all after:duration-300 peer-checked:after:translate-x-5 shadow-sm group-active:after:w-5.5 peer-checked:group-active:after:translate-x-4" />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
title,
|
||||
desc,
|
||||
@@ -165,73 +131,48 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
|
||||
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);
|
||||
// Application roles (Hasura) — drives the role dropdowns.
|
||||
const rolesQ = useAppRoles();
|
||||
|
||||
// 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);
|
||||
// In-session workspace preferences. These have NO merchant-settings backend
|
||||
// (see [R6]) so they are not persisted; the operational controls that would
|
||||
// need persistence show an AwaitingApi notice instead of saving silently.
|
||||
const [form, setForm] = useState<MerchantSettings>({ ...DEFAULTS });
|
||||
|
||||
// First-run seeding: fill region/role defaults from the live tenant once it
|
||||
// arrives (used at runtime by the Add User dialog / region label).
|
||||
const seededRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (seededRef.current || !tenant) return;
|
||||
seededRef.current = true;
|
||||
const seed = (prev: MerchantSettings): MerchantSettings => ({
|
||||
setForm((prev) => ({
|
||||
...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]);
|
||||
|
||||
// Live outlets only — no fabricated fallback. Render whatever the API returns.
|
||||
const cleanOutlets = useMemo(() => {
|
||||
return outlets.map((loc, idx) => {
|
||||
// If the location name is a mock name (doesn't contain store context), replace with Coimbatore locations
|
||||
const nameStr = fstr(loc.locationname);
|
||||
const isMockTest = !nameStr.toLowerCase().includes('stores') &&
|
||||
!nameStr.toLowerCase().includes('outlet') &&
|
||||
!nameStr.toLowerCase().includes('hub') &&
|
||||
!nameStr.toLowerCase().includes('ragul');
|
||||
|
||||
const localData = LOCAL_OUTLETS_DATA[idx % LOCAL_OUTLETS_DATA.length];
|
||||
|
||||
return {
|
||||
locationid: fstr(loc.locationid) || String(1090 + idx),
|
||||
locationname: isMockTest ? localData.name : nameStr,
|
||||
suburb: isMockTest ? localData.suburb : (fstr(loc.suburb) || localData.suburb),
|
||||
city: isMockTest ? localData.city : (fstr(loc.city) || localData.city),
|
||||
postcode: isMockTest ? localData.postcode : (fstr(loc.postcode) || localData.postcode),
|
||||
status: fstr(loc.status) || 'Active',
|
||||
opentime: fstr(loc.opentime) || '2026-06-04T09:00:00Z',
|
||||
closetime: fstr(loc.closetime) || '2026-06-04T22:00:00Z',
|
||||
deliverymins: isMockTest ? localData.mins : (fnum(loc.deliverymins) || localData.mins),
|
||||
deliveryradius: isMockTest ? localData.radius : (fnum(loc.deliveryradius) || localData.radius),
|
||||
};
|
||||
});
|
||||
return outlets.map((loc, idx) => ({
|
||||
locationid: fstr(loc.locationid) || String(idx),
|
||||
locationname: fstr(loc.locationname) || '—',
|
||||
suburb: fstr(loc.suburb),
|
||||
city: fstr(loc.city),
|
||||
postcode: fstr(loc.postcode),
|
||||
status: fstr(loc.status) || '—',
|
||||
opentime: fstr(loc.opentime),
|
||||
closetime: fstr(loc.closetime),
|
||||
deliverymins: fnum(loc.deliverymins),
|
||||
deliveryradius: fnum(loc.deliveryradius),
|
||||
}));
|
||||
}, [outlets]);
|
||||
|
||||
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 },
|
||||
@@ -241,7 +182,23 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
|
||||
{ key: 'preferences', label: 'Preferences', icon: SlidersHorizontal },
|
||||
];
|
||||
|
||||
const roleOptions = [1, 2, 3, 4, 6];
|
||||
// Build role options from the live app-roles API; fall back to the known
|
||||
// numeric roles + roleName() helper when the API has no rows/names.
|
||||
const roleOptions = useMemo<Array<{ id: number; name: string }>>(() => {
|
||||
const rows = rolesQ.data ?? [];
|
||||
const mapped = rows
|
||||
.map((r) => {
|
||||
const id = fnum((r as Record<string, unknown>).roleid);
|
||||
const name =
|
||||
fstr((r as Record<string, unknown>).rolename) ||
|
||||
fstr((r as Record<string, unknown>).name) ||
|
||||
roleName(id);
|
||||
return { id, name };
|
||||
})
|
||||
.filter((r) => r.id > 0);
|
||||
if (mapped.length) return mapped;
|
||||
return [1, 2, 3, 4, 6].map((id) => ({ id, name: roleName(id) }));
|
||||
}, [rolesQ.data]);
|
||||
|
||||
return (
|
||||
<div className="space-y-lg animate-in fade-in duration-300 relative font-sans text-slate-700">
|
||||
@@ -398,7 +355,7 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editable contact (persisted locally) */}
|
||||
{/* Customer support contacts — live (read-only) tenant values. */}
|
||||
<div className="space-y-sm">
|
||||
<span className="text-xs font-bold text-slate-500 uppercase tracking-wider block">Customer Support & Contacts</span>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-md mt-2">
|
||||
@@ -408,10 +365,10 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={form.contactEmail}
|
||||
onChange={(e) => set('contactEmail', e.target.value)}
|
||||
className="w-full border border-slate-200 rounded-xl py-3 px-4 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm"
|
||||
placeholder="store@example.com"
|
||||
value={fstr(tenant?.primaryemail) || ''}
|
||||
readOnly
|
||||
className="w-full border border-slate-200 rounded-xl py-3 px-4 bg-slate-50/40 outline-none transition-all text-slate-800 font-semibold text-sm shadow-sm"
|
||||
placeholder="—"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
@@ -420,10 +377,10 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.contactPhone}
|
||||
onChange={(e) => set('contactPhone', e.target.value)}
|
||||
className="w-full border border-slate-200 rounded-xl py-3 px-4 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm"
|
||||
placeholder="9876543210"
|
||||
value={fstr(tenant?.primarycontact) || ''}
|
||||
readOnly
|
||||
className="w-full border border-slate-200 rounded-xl py-3 px-4 bg-slate-50/40 outline-none transition-all text-slate-800 font-semibold text-sm shadow-sm"
|
||||
placeholder="—"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -446,7 +403,7 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
|
||||
{locationsQ.isLoading ? (
|
||||
<div className="text-center py-lg text-slate-400 font-medium text-sm">Loading live outlets…</div>
|
||||
) : cleanOutlets.length === 0 ? (
|
||||
<div className="text-center py-lg text-slate-400 font-medium text-sm">No outlets found for this store.</div>
|
||||
<div className="text-center py-lg text-slate-400 font-medium text-sm">No outlets configured yet.</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-md max-h-[38rem] overflow-y-auto pr-1 scrollbar-thin">
|
||||
{cleanOutlets.map((loc, i) => (
|
||||
@@ -462,7 +419,7 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
|
||||
<p className="font-bold text-slate-800 text-sm truncate leading-tight group-hover:text-purple-950 transition-colors">{loc.locationname}</p>
|
||||
<p className="text-xs text-slate-450 mt-1.5 flex items-center gap-1">
|
||||
<MapPin size={12} className="shrink-0 text-slate-400" />
|
||||
<span className="truncate">{loc.suburb}, {loc.city}</span>
|
||||
<span className="truncate">{[loc.suburb, loc.city].filter(Boolean).join(', ') || '—'}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -480,20 +437,22 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-slate-450 uppercase font-bold block">Delivery Range</span>
|
||||
<p className="font-bold text-slate-700 text-xs">
|
||||
Up to {loc.deliveryradius / 1000} km
|
||||
{loc.deliveryradius ? `Up to ${loc.deliveryradius / 1000} km` : '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-slate-455 uppercase font-bold block">Delivery Speed</span>
|
||||
<p className="font-bold text-slate-700 text-xs">
|
||||
{loc.deliverymins} mins avg
|
||||
{loc.deliverymins ? `${loc.deliverymins} mins avg` : '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1 col-span-2 border-t border-slate-100 pt-2 mt-1">
|
||||
<span className="text-[10px] text-slate-455 uppercase font-bold block">Opening Hours</span>
|
||||
<p className="font-bold text-slate-750 text-xs flex items-center gap-1.5 mt-0.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
||||
Open: {formatFriendlyTime(loc.opentime)} – {formatFriendlyTime(loc.closetime)}
|
||||
{loc.opentime && loc.closetime
|
||||
? `Open: ${formatFriendlyTime(loc.opentime)} – ${formatFriendlyTime(loc.closetime)}`
|
||||
: 'Hours not set'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -511,144 +470,47 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
|
||||
|
||||
{activeTab === 'delivery' && (
|
||||
<div className="bg-white border border-slate-200/60 p-6 rounded-2xl shadow-sm space-y-lg animate-in fade-in duration-200">
|
||||
{/* Group 1: Order Prep & Timings */}
|
||||
<div className="space-y-sm">
|
||||
<span className="text-xs font-bold text-purple-800 bg-purple-50/70 border border-purple-100/50 px-3 py-1 rounded-full uppercase tracking-wider">
|
||||
Order Prep & Timings
|
||||
</span>
|
||||
<div className="divide-y divide-slate-100/70 mt-2">
|
||||
<Row title="Preparation Time" desc="Minutes a store needs before pickup.">
|
||||
<div className="relative rounded-xl shadow-sm">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.prepMins}
|
||||
onChange={(e) => set('prepMins', Number(e.target.value))}
|
||||
className="w-28 pr-9 pl-4 py-2 border border-slate-200 rounded-xl text-right font-bold text-slate-700 bg-slate-50/40 hover:bg-slate-50 focus:bg-white focus:outline-none focus:border-purple-500 focus:ring-2 focus:ring-purple-500/10 transition-all text-sm font-mono"
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<span className="text-slate-400 font-semibold text-[10px] uppercase">min</span>
|
||||
</div>
|
||||
</div>
|
||||
</Row>
|
||||
<Row title="Delivery Window" desc="Estimated delivery time from store to customer.">
|
||||
<div className="relative rounded-xl shadow-sm">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.deliveryWindowMins}
|
||||
onChange={(e) => set('deliveryWindowMins', Number(e.target.value))}
|
||||
className="w-28 pr-9 pl-4 py-2 border border-slate-200 rounded-xl text-right font-bold text-slate-700 bg-slate-50/40 hover:bg-slate-50 focus:bg-white focus:outline-none focus:border-purple-500 focus:ring-2 focus:ring-purple-500/10 transition-all text-sm font-mono"
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<span className="text-slate-400 font-semibold text-[10px] uppercase">min</span>
|
||||
</div>
|
||||
</div>
|
||||
</Row>
|
||||
<Row title="Cancellation Window" desc="Seconds a customer can cancel for free.">
|
||||
<div className="relative rounded-xl shadow-sm">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.cancelWindowSecs}
|
||||
onChange={(e) => set('cancelWindowSecs', Number(e.target.value))}
|
||||
className="w-28 pr-9 pl-4 py-2 border border-slate-200 rounded-xl text-right font-bold text-slate-700 bg-slate-50/40 hover:bg-slate-50 focus:bg-white focus:outline-none focus:border-purple-500 focus:ring-2 focus:ring-purple-500/10 transition-all text-sm font-mono"
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<span className="text-slate-400 font-semibold text-[10px] uppercase">sec</span>
|
||||
</div>
|
||||
</div>
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Group 2: Delivery Charges & Dispatch */}
|
||||
<div className="space-y-sm">
|
||||
<span className="text-xs font-bold text-purple-800 bg-purple-50/70 border border-purple-100/50 px-3 py-1 rounded-full uppercase tracking-wider">
|
||||
Delivery Charges & Dispatch
|
||||
</span>
|
||||
<div className="divide-y divide-slate-100/70 mt-2">
|
||||
<Row title="Default Delivery Charge" desc="Flat fee added to each delivery order.">
|
||||
<div className="relative rounded-xl shadow-sm">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<span className="text-slate-400 font-bold text-sm">₹</span>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.deliveryCharge}
|
||||
onChange={(e) => set('deliveryCharge', Number(e.target.value))}
|
||||
className="w-28 pl-7 pr-4 py-2 border border-slate-200 rounded-xl text-right font-bold text-slate-700 bg-slate-50/40 hover:bg-slate-50 focus:bg-white focus:outline-none focus:border-purple-500 focus:ring-2 focus:ring-purple-500/10 transition-all text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
<span className="text-xs font-bold text-slate-450 uppercase tracking-widest block">Delivery</span>
|
||||
<h2 className="text-xl font-bold text-slate-900 mt-1">Order Prep, Timings & Dispatch</h2>
|
||||
</div>
|
||||
{/* No merchant-settings API yet — these operational controls cannot be persisted live. */}
|
||||
<AwaitingApi label="Merchant settings persistence" api="[R6]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'payment' && (
|
||||
<div className="bg-white border border-slate-200/60 p-6 rounded-2xl shadow-sm space-y-lg animate-in fade-in duration-200">
|
||||
{/* Group 1: Checkout Gateways */}
|
||||
<div>
|
||||
<span className="text-xs font-bold text-slate-450 uppercase tracking-widest block">Payment & Tax</span>
|
||||
<h2 className="text-xl font-bold text-slate-900 mt-1">Checkout & Taxation</h2>
|
||||
</div>
|
||||
|
||||
{/* Live (read-only) tenant payment details. */}
|
||||
<div className="space-y-sm">
|
||||
<span className="text-xs font-bold text-purple-800 bg-purple-50/70 border border-purple-100/50 px-3 py-1 rounded-full uppercase tracking-wider">
|
||||
Checkout Gateways
|
||||
Store Payment Details
|
||||
</span>
|
||||
<div className="divide-y divide-slate-100/70 mt-2">
|
||||
<Row title="Cash on Delivery" desc="Allow customers to pay on delivery.">
|
||||
<Toggle checked={form.codEnabled} onChange={() => set('codEnabled', !form.codEnabled)} />
|
||||
<Row title="Minimum Order Value" desc="Smallest order a customer can place (from store profile).">
|
||||
<span className="font-bold text-slate-700 text-sm font-mono">
|
||||
{tenant && fnum(tenant.minorder) ? `₹${fnum(tenant.minorder).toLocaleString('en-IN')}` : '—'}
|
||||
</span>
|
||||
</Row>
|
||||
<Row title="Online Payments" desc="Accept UPI / card / wallet at checkout.">
|
||||
<Toggle checked={form.onlinePaymentEnabled} onChange={() => set('onlinePaymentEnabled', !form.onlinePaymentEnabled)} />
|
||||
<Row title="Payment Gateway ID" desc="Configured payment type for this store.">
|
||||
<span className="font-mono font-black bg-purple-100 px-3 py-1.5 rounded-xl border border-purple-200/40 text-xs">
|
||||
{tenant && fnum(tenant.paymenttype) ? fnum(tenant.paymenttype) : '—'}
|
||||
</span>
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Group 2: Taxation & Rules */}
|
||||
{/* Editable checkout gateways + tax rules have no persistence backend. */}
|
||||
<div className="space-y-sm">
|
||||
<span className="text-xs font-bold text-purple-800 bg-purple-50/70 border border-purple-100/50 px-3 py-1 rounded-full uppercase tracking-wider">
|
||||
Taxation & Cart Limits
|
||||
Checkout Gateways & Taxation
|
||||
</span>
|
||||
<div className="divide-y divide-slate-100/70 mt-2">
|
||||
<Row title="Default Tax Rate" desc="Applied to taxable catalogue items.">
|
||||
<div className="relative rounded-xl shadow-sm">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={form.defaultTaxPercent}
|
||||
onChange={(e) => set('defaultTaxPercent', Number(e.target.value))}
|
||||
className="w-28 pr-7 pl-4 py-2 border border-slate-200 rounded-xl text-right font-bold text-slate-700 bg-slate-50/40 hover:bg-slate-50 focus:bg-white focus:outline-none focus:border-purple-500 focus:ring-2 focus:ring-purple-500/10 transition-all text-sm font-mono"
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<span className="text-slate-400 font-bold text-sm">%</span>
|
||||
</div>
|
||||
</div>
|
||||
</Row>
|
||||
<Row title="Minimum Order Value" desc="Smallest order a customer can place.">
|
||||
<div className="relative rounded-xl shadow-sm">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<span className="text-slate-400 font-bold text-sm">₹</span>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.minOrderValue}
|
||||
onChange={(e) => set('minOrderValue', Number(e.target.value))}
|
||||
className="w-28 pl-7 pr-4 py-2 border border-slate-200 rounded-xl text-right font-bold text-slate-700 bg-slate-50/40 hover:bg-slate-50 focus:bg-white focus:outline-none focus:border-purple-500 focus:ring-2 focus:ring-purple-500/10 transition-all text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API synchronization details */}
|
||||
<div className="p-4 bg-purple-50/50 border border-purple-100/50 rounded-2xl text-purple-900 text-xs font-semibold flex items-center justify-between">
|
||||
<span className="text-slate-650 font-bold text-xs">Payment Gateway ID</span>
|
||||
<span className="font-mono font-black bg-purple-100 px-3 py-1.5 rounded-xl border border-purple-200/40 text-xs">{fnum(tenant?.paymenttype) || 'PAY-MOCK-99'}</span>
|
||||
<AwaitingApi className="mt-2" label="Merchant settings persistence" api="[R6]" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -681,92 +543,28 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
|
||||
className="border border-slate-200 bg-slate-50/40 hover:bg-slate-50 focus:bg-white rounded-xl py-2 px-3 font-bold text-slate-700 outline-none cursor-pointer focus:border-purple-500 transition-all text-sm shadow-sm"
|
||||
>
|
||||
{roleOptions.map((r) => (
|
||||
<option key={r} value={r}>{roleName(r)}</option>
|
||||
<option key={r.id} value={r.id}>{r.name}</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="border border-slate-200 bg-slate-50/40 hover:bg-slate-50 focus:bg-white rounded-xl py-2 px-3 font-bold text-slate-700 outline-none cursor-pointer focus:border-purple-500 transition-all text-sm shadow-sm"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 font-medium mt-2 px-4">
|
||||
Region and default-role are in-session workspace preferences applied at runtime; they are not saved to a backend.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Group 2: Notifications */}
|
||||
{/* Group 2: Notifications, sync interval & sandbox — no persistence backend. */}
|
||||
<div className="space-y-sm">
|
||||
<span className="text-xs font-bold text-purple-800 bg-purple-50/70 border border-purple-100/50 px-3 py-1 rounded-full uppercase tracking-wider">
|
||||
Notifications
|
||||
Notifications, Sync & Test Mode
|
||||
</span>
|
||||
<div className="divide-y divide-slate-100/70 mt-2">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Group 3: Test Mode (Sandbox) */}
|
||||
<div className="space-y-sm">
|
||||
<span className="text-xs font-bold text-purple-800 bg-purple-50/70 border border-purple-100/50 px-3 py-1 rounded-full uppercase tracking-wider">
|
||||
Test Mode (Sandbox)
|
||||
</span>
|
||||
<div className="divide-y divide-slate-100/70 mt-2">
|
||||
<Row title="Sandbox Mode" desc="Simulate warning states for testing without affecting live operations.">
|
||||
<Toggle checked={form.sandboxMode} onChange={() => set('sandboxMode', !form.sandboxMode)} />
|
||||
</Row>
|
||||
</div>
|
||||
<AwaitingApi className="mt-2" label="Merchant settings persistence" api="[R6]" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Floating Save Actions Bar (Frosted Glass) */}
|
||||
<div className={`fixed bottom-6 left-6 sm:left-[28%] right-6 bg-white/75 backdrop-blur-md border border-slate-200/80 rounded-2xl p-4 shadow-[0_20px_50px_rgba(0,0,0,0.12)] flex flex-col sm:flex-row sm:items-center justify-between gap-4 z-40 transition-all duration-500 ease-out transform select-none ${
|
||||
activeTab === 'users' ? 'hidden' :
|
||||
dirty ? 'translate-y-0 opacity-100' : 'translate-y-16 opacity-0 pointer-events-none'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-amber-500 animate-pulse shrink-0" />
|
||||
<span className="text-sm font-bold text-slate-800">You have unsaved configuration changes</span>
|
||||
</div>
|
||||
<div className="flex gap-2.5">
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="px-4 py-2.5 border border-slate-200 bg-white/50 hover:bg-slate-100 rounded-xl text-xs font-bold text-slate-650 transition-all cursor-pointer flex items-center gap-1.5 active:scale-95 shadow-sm"
|
||||
>
|
||||
<RotateCcw size={14} /> Reset
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-4 py-2.5 bg-purple-650 hover:bg-purple-755 text-white rounded-xl text-xs font-bold transition-all cursor-pointer shadow-sm flex items-center gap-1.5 active:scale-95 border-none"
|
||||
>
|
||||
<Check size={14} /> 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>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
254
src/components/UserStorePage.tsx
Normal file
254
src/components/UserStorePage.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { AlertTriangle, LayoutDashboard, User, Mail, Phone, Store, ShieldCheck } from 'lucide-react';
|
||||
import {
|
||||
useFiestaTenantLocations,
|
||||
useFiestaLocationSummary,
|
||||
FIESTA_TENANT_ID,
|
||||
} from '../services/fiestaQueries';
|
||||
import { str as fstr, num as fnum, roleName } from '../services/fiestaApi';
|
||||
import type { AuthUser } from '../services/auth';
|
||||
import Header from './Header';
|
||||
import StoreDetailView from './StoreDetailView';
|
||||
import UserStoreSidebar, { type UserNavItem } from './UserStoreSidebar';
|
||||
|
||||
interface UserStorePageProps {
|
||||
/** Returns to the login screen. */
|
||||
onLogout: () => void;
|
||||
/** The signed-in user, allocated to a single store via their applocationid. */
|
||||
user: AuthUser;
|
||||
}
|
||||
|
||||
// Sidebar navigation. Add entries here as new user sections are built — each id
|
||||
// gets a matching branch in `renderSection` below.
|
||||
const NAV_ITEMS: UserNavItem[] = [
|
||||
{ id: 'console', label: 'Store Console', icon: LayoutDashboard },
|
||||
{ id: 'account', label: 'My Account', icon: User },
|
||||
];
|
||||
|
||||
type StoreShape = React.ComponentProps<typeof StoreDetailView>['store'];
|
||||
|
||||
/**
|
||||
* Landing workspace for a non-admin user. A user is allocated to exactly one
|
||||
* store (their applocationid), so this resolves that store from the tenant's
|
||||
* outlet list and renders the full store console scoped to it — no registry,
|
||||
* no other stores. Layout mirrors the admin console (fixed Header + left rail).
|
||||
*/
|
||||
export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [activeSection, setActiveSection] = useState<string>('console');
|
||||
|
||||
const locationsQ = useFiestaTenantLocations(FIESTA_TENANT_ID);
|
||||
const locSummaryQ = useFiestaLocationSummary(FIESTA_TENANT_ID);
|
||||
|
||||
const locations = locationsQ.data ?? [];
|
||||
const summaries = locSummaryQ.data ?? [];
|
||||
|
||||
// Resolve the user's store. Most tenants have exactly ONE store, so when the
|
||||
// tenant has a single location we just use it — no id matching needed. Only if
|
||||
// a tenant has multiple outlets do we disambiguate by the user's applocationid
|
||||
// (accepting a row whose locationid equals it too), then by locationid.
|
||||
const apploc = user.applocationid;
|
||||
const matchedLoc =
|
||||
locations.length === 1
|
||||
? locations[0]
|
||||
: (locations.find((l) => apploc != null && fnum(l.applocationid) === apploc) ??
|
||||
locations.find((l) => apploc != null && fnum(l.locationid) === apploc) ??
|
||||
locations.find((l) => user.locationid != null && fnum(l.locationid) === user.locationid) ??
|
||||
null);
|
||||
|
||||
// Resolve the locationid the store console queries by. Prefer the matched
|
||||
// outlet, then the user's own locationid, then the applocationid as a last
|
||||
// resort so the console still scopes to a single store rather than all of them.
|
||||
const resolvedLocationId =
|
||||
(matchedLoc && fnum(matchedLoc.locationid)) || user.locationid || user.applocationid || 0;
|
||||
|
||||
const storeName =
|
||||
(matchedLoc && fstr(matchedLoc.locationname)) ||
|
||||
user.applocation ||
|
||||
user.locationname ||
|
||||
(resolvedLocationId ? `Store ${resolvedLocationId}` : 'Your Store');
|
||||
|
||||
const profile = {
|
||||
name: user.name,
|
||||
role: user.roleid ? roleName(user.roleid) : 'Team Member',
|
||||
email: user.email,
|
||||
};
|
||||
|
||||
const initials =
|
||||
user.name
|
||||
.split(' ')
|
||||
.map((w) => w[0])
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.join('')
|
||||
.toUpperCase() || 'NA';
|
||||
|
||||
const handleHelp = () =>
|
||||
alert('nearledaily Store User Help — contact your store administrator for access or data questions.');
|
||||
|
||||
// Build the store shape StoreDetailView expects, mirroring the App registry
|
||||
// mapping so the console renders identically to the admin's store view.
|
||||
const buildStore = (): StoreShape => {
|
||||
const sum = summaries.find((s) => s.locationid === resolvedLocationId);
|
||||
const status = matchedLoc ? fstr(matchedLoc.status) || 'Active' : 'Active';
|
||||
return {
|
||||
locationid: resolvedLocationId,
|
||||
name: storeName,
|
||||
zone: matchedLoc
|
||||
? [fstr(matchedLoc.suburb), fstr(matchedLoc.city)].filter(Boolean).join(', ') || 'Coimbatore'
|
||||
: 'Coimbatore',
|
||||
deliveries: sum?.delivered ?? 0,
|
||||
sales: `${(sum?.total ?? 0).toLocaleString('en-IN')} orders`,
|
||||
orders: Math.max(sum?.delivered ?? 0, sum?.total ?? 0),
|
||||
staff: (matchedLoc && (fstr(matchedLoc.contactno) || fstr(matchedLoc.email))) || user.name || '—',
|
||||
color: status.toLowerCase() === 'active' ? 'emerald' : 'amber',
|
||||
status,
|
||||
};
|
||||
};
|
||||
|
||||
// ── My Account: read-only profile + store binding ──────────────────────────
|
||||
const renderAccount = () => (
|
||||
<div className="max-w-2xl animate-in fade-in duration-300">
|
||||
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a] mb-1">My Account</h1>
|
||||
<p className="text-zinc-500 font-sans text-xs mb-6">Your profile and the store you’re assigned to.</p>
|
||||
|
||||
<div className="bg-white border border-slate-200/70 rounded-2xl shadow-sm overflow-hidden">
|
||||
<div className="bg-gradient-to-br from-[#581c87] via-purple-800 to-purple-950 p-6 text-white flex items-center gap-4">
|
||||
<span className="w-14 h-14 rounded-full bg-white/15 ring-2 ring-white/30 flex items-center justify-center text-lg font-bold tracking-wide">
|
||||
{initials}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="font-bold text-lg truncate">{user.name}</p>
|
||||
<span className="inline-flex items-center gap-1.5 mt-1 text-[10px] font-bold uppercase tracking-wider text-purple-100 bg-white/15 border border-white/20 px-2 py-0.5 rounded-full">
|
||||
<ShieldCheck size={11} /> {profile.role}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="p-6 divide-y divide-slate-100 text-sm">
|
||||
<div className="flex items-center gap-3 py-3">
|
||||
<Mail size={15} className="text-slate-400 shrink-0" />
|
||||
<dt className="w-32 text-xs font-bold text-slate-400 uppercase tracking-wider">Email</dt>
|
||||
<dd className="text-slate-700 font-medium break-all">{user.email || '—'}</dd>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 py-3">
|
||||
<Phone size={15} className="text-slate-400 shrink-0" />
|
||||
<dt className="w-32 text-xs font-bold text-slate-400 uppercase tracking-wider">Phone</dt>
|
||||
<dd className="text-slate-700 font-medium">{user.contactno || '—'}</dd>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 py-3">
|
||||
<Store size={15} className="text-slate-400 shrink-0" />
|
||||
<dt className="w-32 text-xs font-bold text-slate-400 uppercase tracking-wider">App Location</dt>
|
||||
<dd className="text-slate-700 font-medium">{user.applocation || storeName || '—'}</dd>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 py-3">
|
||||
<ShieldCheck size={15} className="text-slate-400 shrink-0" />
|
||||
<dt className="w-32 text-xs font-bold text-slate-400 uppercase tracking-wider">App Location ID</dt>
|
||||
<dd className="text-slate-700 font-medium font-mono">{user.applocationid ?? '—'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render the active section. New sidebar items add a branch here; the default
|
||||
// keeps anything not-yet-built from breaking the page.
|
||||
const renderSection = () => {
|
||||
if (activeSection === 'account') return renderAccount();
|
||||
|
||||
// The store console needs a resolved store, so gate it on the load state.
|
||||
if (locationsQ.isLoading || locSummaryQ.isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-24">
|
||||
<div className="w-7 h-7 border-2 border-[#581c87] border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-xs font-semibold text-slate-500">Loading your store…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Distinguish a real load failure from "no store assigned" — otherwise an
|
||||
// API outage would wrongly tell the user to contact their admin.
|
||||
if (locationsQ.isError || locSummaryQ.isError) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="bg-white border border-slate-200/70 rounded-3xl p-10 text-center max-w-md shadow-[0_10px_40px_rgba(0,0,0,0.08)]">
|
||||
<div className="mx-auto h-16 w-16 rounded-2xl bg-rose-50 text-rose-600 ring-1 ring-rose-100 flex items-center justify-center mb-6">
|
||||
<AlertTriangle size={30} />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 tracking-tight mb-3">Couldn’t load your store</h1>
|
||||
<p className="text-[15px] text-slate-500 leading-relaxed mb-6">
|
||||
We couldn’t reach the store service. Check your connection and try again.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
locationsQ.refetch();
|
||||
locSummaryQ.refetch();
|
||||
}}
|
||||
className="px-5 py-2.5 bg-[#581c87] hover:bg-purple-800 text-white text-sm font-bold rounded-xl cursor-pointer transition-colors shadow-sm"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!resolvedLocationId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="bg-white border border-slate-200/70 rounded-3xl p-10 text-center max-w-md shadow-[0_10px_40px_rgba(0,0,0,0.08)]">
|
||||
<div className="mx-auto h-16 w-16 rounded-2xl bg-amber-50 text-amber-600 ring-1 ring-amber-100 flex items-center justify-center mb-6">
|
||||
<AlertTriangle size={30} />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 tracking-tight mb-3">No store assigned</h1>
|
||||
<p className="text-[15px] text-slate-500 leading-relaxed">
|
||||
Your account isn’t linked to a store yet. Please contact your administrator to be
|
||||
allocated to a store location.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// canManage=false hides write actions in the console. NOTE: this is a UI-only
|
||||
// restriction — the backend must also enforce role-based authorization on the
|
||||
// write endpoints, since a hidden button doesn't stop a direct API call.
|
||||
return <StoreDetailView store={buildStore()} canManage={false} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#f8fafc] text-[#0f172a] font-sans antialiased">
|
||||
<Header
|
||||
isSidebarOpen={sidebarOpen}
|
||||
onToggleSidebar={() => setSidebarOpen((s) => !s)}
|
||||
onHelpClick={handleHelp}
|
||||
onLogoutClick={onLogout}
|
||||
profile={profile}
|
||||
/>
|
||||
|
||||
<div className="flex pt-20">
|
||||
<UserStoreSidebar
|
||||
items={NAV_ITEMS}
|
||||
activeId={activeSection}
|
||||
onSelect={setActiveSection}
|
||||
isOpen={sidebarOpen}
|
||||
/>
|
||||
|
||||
<main
|
||||
className={`flex-1 min-w-0 min-h-[calc(100vh-80px)] transition-all duration-300 ${
|
||||
sidebarOpen ? 'md:pl-64' : 'md:pl-20'
|
||||
}`}
|
||||
>
|
||||
<div className="w-full p-container-margin md:p-xl space-y-lg transition-all duration-300">
|
||||
{renderSection()}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
src/components/UserStoreSidebar.tsx
Normal file
62
src/components/UserStoreSidebar.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { type LucideIcon } from 'lucide-react';
|
||||
|
||||
/** One entry in the user store sidebar. Add to `UserStorePage`'s `NAV_ITEMS`. */
|
||||
export interface UserNavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
interface UserStoreSidebarProps {
|
||||
items: UserNavItem[];
|
||||
activeId: string;
|
||||
onSelect: (id: string) => void;
|
||||
/** Collapsed → icon-only rail; expanded → labels visible. */
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user store page's left rail. Visually identical to the admin `Sidebar`
|
||||
* (fixed, sits under the fixed Header, collapses to an icon rail) but driven by
|
||||
* a generic items list so new user sections can be added without touching this.
|
||||
*/
|
||||
export default function UserStoreSidebar({ items, activeId, onSelect, isOpen }: UserStoreSidebarProps) {
|
||||
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">
|
||||
{items.map((item) => {
|
||||
const IconComponent = item.icon;
|
||||
const isActive = activeId === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onSelect(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>
|
||||
);
|
||||
}
|
||||
@@ -28,7 +28,8 @@ import {
|
||||
Coins
|
||||
} from 'lucide-react';
|
||||
import { useFiestaUsers, useFiestaCreateUser } from '../services/fiestaQueries';
|
||||
import { FIESTA_TENANT_ID, str as fstr, roleName } from '../services/fiestaApi';
|
||||
import { useAppRoles } from '../services/queries';
|
||||
import { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../services/fiestaApi';
|
||||
|
||||
interface UsersPanelProps {
|
||||
tenantId?: number;
|
||||
@@ -50,9 +51,46 @@ const ROLE_THEMES: Record<number, { bg: string; text: string; border: string; la
|
||||
6: { bg: 'bg-indigo-50/75', text: 'text-indigo-700', border: 'border-indigo-100', label: 'Cashier' },
|
||||
};
|
||||
|
||||
/** Cosmetic icon + blurb per role id, used to keep the add-user role cards styled. */
|
||||
const ROLE_META: Record<number, { icon: typeof ShieldAlert; desc: string }> = {
|
||||
1: { icon: ShieldAlert, desc: 'Full business access' },
|
||||
2: { icon: Shield, desc: 'Operations control' },
|
||||
3: { icon: SlidersHorizontal, desc: 'Manage store settings' },
|
||||
4: { icon: User, desc: 'Standard staff duties' },
|
||||
6: { icon: Coins, desc: 'Checkout & registers' },
|
||||
};
|
||||
|
||||
/** Fallback role choices when the app-roles API returns nothing. */
|
||||
const FALLBACK_ROLE_CHOICES = [
|
||||
{ id: 1, label: 'Owner', desc: 'Full business access', icon: ShieldAlert },
|
||||
{ id: 2, label: 'Manager', desc: 'Operations control', icon: Shield },
|
||||
{ id: 3, label: 'Admin', desc: 'Manage store settings', icon: SlidersHorizontal },
|
||||
{ id: 4, label: 'Staff', desc: 'Standard staff duties', icon: User },
|
||||
{ id: 6, label: 'Cashier', desc: 'Checkout & registers', icon: Coins },
|
||||
];
|
||||
|
||||
export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUserRole = 4 }: UsersPanelProps) {
|
||||
const usersQ = useFiestaUsers({ tenantid: tenantId, pagesize: 100 });
|
||||
const createUserMut = useFiestaCreateUser();
|
||||
const rolesQ = useAppRoles();
|
||||
|
||||
// Selectable roles for the Add User modal — driven by the live app-roles API,
|
||||
// matched to local icon/desc styling by roleid; falls back to the static list.
|
||||
const roleChoices = React.useMemo(() => {
|
||||
const rows = rolesQ.data ?? [];
|
||||
const mapped = rows
|
||||
.map((r) => {
|
||||
const id = fnum((r as Record<string, unknown>).roleid);
|
||||
const label =
|
||||
fstr((r as Record<string, unknown>).rolename) ||
|
||||
fstr((r as Record<string, unknown>).name) ||
|
||||
roleName(id);
|
||||
const meta = ROLE_META[id];
|
||||
return { id, label, desc: meta?.desc ?? '', icon: meta?.icon ?? User };
|
||||
})
|
||||
.filter((r) => r.id > 0);
|
||||
return mapped.length ? mapped : FALLBACK_ROLE_CHOICES;
|
||||
}, [rolesQ.data]);
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [userRoleFilter, setUserRoleFilter] = useState<number | 'ALL'>('ALL');
|
||||
@@ -378,13 +416,7 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
|
||||
<div className="space-y-2">
|
||||
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">SELECT ACCOUNT ROLE (*)</label>
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
{[
|
||||
{ id: 1, label: 'Owner', desc: 'Full business access', icon: ShieldAlert },
|
||||
{ id: 2, label: 'Manager', desc: 'Operations control', icon: Shield },
|
||||
{ id: 3, label: 'Admin', desc: 'Manage store settings', icon: SlidersHorizontal },
|
||||
{ id: 4, label: 'Staff', desc: 'Standard staff duties', icon: User },
|
||||
{ id: 6, label: 'Cashier', desc: 'Checkout & registers', icon: Coins },
|
||||
].map((r) => {
|
||||
{roleChoices.map((r) => {
|
||||
const isSelected = newUser.roleid === r.id;
|
||||
const Icon = r.icon;
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user