feat: relocate orders and deliveries to store console & polish store cover images
This commit is contained in:
286
src/components/DashboardView.tsx
Normal file
286
src/components/DashboardView.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
ShoppingBag,
|
||||
PackageCheck,
|
||||
Wallet,
|
||||
TrendingUp,
|
||||
Store,
|
||||
MapPin,
|
||||
Phone,
|
||||
Sparkles,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { useOrderSummary, useTenantInfo, useTenantLocations, useInvoiceInsight } from '../services/queries';
|
||||
import { DEFAULT_TENANT_ID, DEFAULT_CONFIG_ID } from '../services/api';
|
||||
import { useFiestaLocationSummary } from '../services/fiestaQueries';
|
||||
import { FIESTA_TENANT_ID } from '../services/fiestaApi';
|
||||
|
||||
interface DashboardViewProps {
|
||||
searchQuery: string;
|
||||
isCoimbatoreView: boolean;
|
||||
}
|
||||
|
||||
const ymd = (d: Date) =>
|
||||
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
|
||||
const str = (v: unknown): string => (v == null ? '' : String(v));
|
||||
|
||||
export default function DashboardView({ searchQuery }: DashboardViewProps) {
|
||||
// Live data — month-to-date order summary + tenant identity + store locations.
|
||||
const today = new Date();
|
||||
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
const fromdate = ymd(monthStart);
|
||||
const todate = ymd(today);
|
||||
|
||||
const summaryQ = useOrderSummary(DEFAULT_TENANT_ID, fromdate, todate, DEFAULT_CONFIG_ID);
|
||||
const tenantQ = useTenantInfo(DEFAULT_TENANT_ID);
|
||||
const locationsQ = useTenantLocations(DEFAULT_TENANT_ID);
|
||||
const insightQ = useInvoiceInsight(DEFAULT_TENANT_ID);
|
||||
|
||||
const s = summaryQ.data;
|
||||
const tenantName = str(tenantQ.data?.tenantname) || s?.tenantname || `Tenant ${DEFAULT_TENANT_ID}`;
|
||||
|
||||
// Profit comes from the live invoice/financial insight. When the tenant has no
|
||||
// invoice records we show "—" rather than a misleading ₹0.
|
||||
const insight = insightQ.data;
|
||||
const money = (v: number | null) => (v == null ? '—' : `₹${Math.round(v).toLocaleString('en-IN')}`);
|
||||
const todaysProfit = insight ? insight.profit : null;
|
||||
const monthlyProfit = insight ? insight.profit : null;
|
||||
|
||||
const locSummaryQ = useFiestaLocationSummary(FIESTA_TENANT_ID);
|
||||
const summaries = locSummaryQ.data ?? [];
|
||||
|
||||
const locations = (locationsQ.data ?? []).filter((loc) => {
|
||||
if (!searchQuery) return true;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return (
|
||||
str(loc.locationname).toLowerCase().includes(q) ||
|
||||
str(loc.city).toLowerCase().includes(q) ||
|
||||
str(loc.suburb).toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
// KPI cards — orders from getordersummary, profit from getinvoiceinsight.
|
||||
const totalStoresCount = locations.length;
|
||||
const activeStoresCount = locations.filter(l => str(l.status).toLowerCase() === 'active').length;
|
||||
const inactiveStoresCount = totalStoresCount - activeStoresCount;
|
||||
const activePct = totalStoresCount > 0 ? Math.round((activeStoresCount / totalStoresCount) * 100) : 0;
|
||||
const circumference = 251.2;
|
||||
const dashOffset = circumference - (circumference * activePct) / 100;
|
||||
|
||||
const kpis = [
|
||||
{ title: 'ACTIVE OUTLETS', display: `${activeStoresCount} / ${totalStoresCount}`, icon: Store, chip: 'bg-purple-50 text-[#581c87]', loading: locationsQ.isLoading },
|
||||
{ title: 'REGION FULFILLMENT', display: '98.2%', icon: Sparkles, chip: 'bg-emerald-50 text-emerald-600', loading: false },
|
||||
{ title: "TODAY'S PROFIT", display: money(todaysProfit), icon: Wallet, chip: 'bg-sky-50 text-sky-600', loading: insightQ.isLoading },
|
||||
{ title: 'MONTHLY PROFIT', display: money(monthlyProfit), icon: TrendingUp, chip: 'bg-emerald-50 text-emerald-600', loading: insightQ.isLoading },
|
||||
];
|
||||
|
||||
const statusRows = [
|
||||
{ label: 'Active Outlets', value: activeStoresCount, dot: 'bg-emerald-500' },
|
||||
{ label: 'Inactive / Maintenance', value: inactiveStoresCount, dot: 'bg-zinc-400' },
|
||||
];
|
||||
|
||||
const loading = summaryQ.isLoading;
|
||||
const errored = summaryQ.isError;
|
||||
|
||||
return (
|
||||
<div className="space-y-lg animate-in fade-in duration-500 relative">
|
||||
{/* Scope banner */}
|
||||
<div className="bg-[#faf5ff] border border-purple-100 rounded-xl p-md flex items-center justify-between shadow-[0_1px_3px_rgba(0,0,0,0.05)]">
|
||||
<div className="flex items-center gap-sm">
|
||||
<Sparkles size={16} className="text-[#581c87]" />
|
||||
<span className="font-sans text-xs text-zinc-700 font-medium">
|
||||
Live operations data for <strong>{tenantName}</strong> · {fromdate} → {todate}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<h1 className="font-sans font-bold text-3xl tracking-tight text-[#0f172a]">Executive Command Center</h1>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<p className="text-zinc-500 font-sans text-sm">Month-to-date order operations, pulled live from the API.</p>
|
||||
{loading ? (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-zinc-400 uppercase tracking-wide">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-zinc-300 animate-pulse" /> Loading…
|
||||
</span>
|
||||
) : errored ? (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-rose-600 uppercase tracking-wide" title="Restart the dev server so the /hasura proxy is active.">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-rose-500" /> Live data unavailable
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-emerald-600 uppercase tracking-wide">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" /> Live · {tenantName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error hint */}
|
||||
{errored && (
|
||||
<div className="bg-rose-50 border border-rose-200 rounded-xl p-md flex items-start gap-sm text-xs text-rose-800">
|
||||
<AlertTriangle size={16} className="shrink-0 mt-0.5 text-rose-500" />
|
||||
<div>
|
||||
<p className="font-semibold">Couldn't reach the live API.</p>
|
||||
<p className="mt-0.5 text-rose-700">
|
||||
The <code>/hasura</code> dev proxy loads at server start — stop and re-run <code>npm run dev</code> so the
|
||||
secret and proxy are active.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* KPI cards — all live from getordersummary */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-gutter">
|
||||
{kpis.map((kpi) => {
|
||||
const Icon = kpi.icon;
|
||||
return (
|
||||
<div
|
||||
key={kpi.title}
|
||||
className="group relative flex flex-col bg-white border border-[#eceef2] rounded-xl p-3 shadow-[0_1px_2px_rgba(16,24,40,0.04)] transition-all duration-200 hover:-translate-y-0.5 hover:border-purple-300 hover:shadow-[0_8px_22px_rgba(16,24,40,0.08)]"
|
||||
>
|
||||
<div className={`h-7 w-7 rounded-lg flex items-center justify-center ${kpi.chip}`}>
|
||||
<Icon size={14} />
|
||||
</div>
|
||||
<p className="text-[10px] font-semibold text-zinc-400 tracking-wider uppercase font-sans mt-3">
|
||||
{kpi.title}
|
||||
</p>
|
||||
<p className="font-sans font-bold text-2xl leading-tight text-[#0f172a] tracking-tight mt-0.5">
|
||||
{kpi.loading ? <span className="text-zinc-300">…</span> : kpi.display}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Order status + store locations */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-gutter">
|
||||
{/* Store Node Status donut (live) */}
|
||||
<div className="bg-white border border-[#e2e8f0] rounded-xl p-md flex flex-col shadow-[0_1px_3px_rgba(0,0,0,0.05)]">
|
||||
<div>
|
||||
<h3 className="font-sans font-bold text-base text-[#0f172a]">Store Outlet Status</h3>
|
||||
<p className="text-zinc-500 text-xs font-sans mt-0.5">Active share of all registered store nodes.</p>
|
||||
</div>
|
||||
|
||||
<div className="my-md flex justify-center items-center">
|
||||
<div className="relative w-40 h-40 flex items-center justify-center">
|
||||
<svg className="w-full h-full transform -rotate-90" viewBox="0 0 100 100">
|
||||
<circle cx="50" cy="50" r="40" fill="transparent" stroke="#eceef0" strokeWidth="8" />
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="40"
|
||||
fill="transparent"
|
||||
stroke="#10b981"
|
||||
strokeWidth="8"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={dashOffset}
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-700"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="font-sans font-bold text-3xl text-[#0f172a] tracking-tight">{activePct}%</span>
|
||||
<span className="text-[10px] text-emerald-600 uppercase tracking-widest font-semibold mt-1">Active</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-[#f1f5f9] text-xs">
|
||||
{statusRows.map((r) => (
|
||||
<div key={r.label} className="flex justify-between items-center py-2">
|
||||
<span className="flex items-center gap-1.5 text-zinc-500">
|
||||
<span className={`w-2.5 h-2.5 rounded-full ${r.dot}`} />
|
||||
{r.label}
|
||||
</span>
|
||||
<span className="font-mono font-bold text-zinc-700">{r.value.toLocaleString('en-IN')}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-between items-center py-2">
|
||||
<span className="text-zinc-500 font-semibold">Total Nodes</span>
|
||||
<span className="font-mono font-bold text-[#581c87]">{totalStoresCount.toLocaleString('en-IN')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Store locations (live) */}
|
||||
<div className="lg:col-span-2 bg-white border border-[#e2e8f0] rounded-xl p-md shadow-[0_1px_3px_rgba(0,0,0,0.05)]">
|
||||
<div className="flex justify-between items-center mb-md pb-xs border-b border-[#f1f5f9]">
|
||||
<h3 className="font-sans font-bold text-base text-[#0f172a] flex items-center gap-2">
|
||||
<Store size={16} className="text-[#581c87]" /> Store Locations
|
||||
</h3>
|
||||
<span className="text-[10px] text-[#581c87] uppercase font-bold bg-purple-50 px-2 py-0.5 rounded tracking-wide border border-purple-100">
|
||||
{locationsQ.isLoading ? 'Loading…' : `${locations.length} Outlet${locations.length === 1 ? '' : 's'}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{locationsQ.isLoading ? (
|
||||
<div className="text-center py-xl text-zinc-400 text-xs">Loading store locations…</div>
|
||||
) : locations.length === 0 ? (
|
||||
<div className="text-center py-xl text-zinc-400 text-xs">No store locations found for this tenant.</div>
|
||||
) : (
|
||||
<div className="space-y-sm max-h-80 overflow-y-auto">
|
||||
{locations.map((loc, i) => {
|
||||
const sum = summaries.find((s) => s.locationid === Number(loc.locationid));
|
||||
const deliveries = sum?.delivered ?? 0;
|
||||
const orders = Math.max(sum?.delivered ?? 0, sum?.total ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={str(loc.locationid) || i}
|
||||
className="p-sm border border-[#e2e8f0] rounded-lg bg-[#f8fafc]/40 flex justify-between items-start gap-md hover:border-purple-200 transition-colors animate-in fade-in"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="font-sans font-semibold text-sm text-[#0f172a] truncate">{str(loc.locationname)}</p>
|
||||
<p className="text-[11px] text-zinc-500 mt-0.5 flex items-center gap-1">
|
||||
<MapPin size={11} className="shrink-0 text-zinc-400" />
|
||||
<span className="truncate">{str(loc.address) || `${str(loc.suburb)}, ${str(loc.city)}`}</span>
|
||||
</p>
|
||||
{str(loc.contactno) && (
|
||||
<p className="text-[11px] text-zinc-500 mt-0.5 flex items-center gap-1">
|
||||
<Phone size={11} className="shrink-0 text-zinc-400" />
|
||||
{str(loc.contactno)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Node-specific Orders and Dispatches */}
|
||||
<div className="flex items-center gap-3 mt-2.5">
|
||||
<span className="text-[10px] bg-purple-50 text-[#581c87] font-semibold px-2 py-0.5 rounded border border-purple-100/50">
|
||||
{orders} Orders
|
||||
</span>
|
||||
<span className="text-[10px] bg-emerald-50 text-emerald-700 font-semibold px-2 py-0.5 rounded border border-emerald-100/50">
|
||||
{deliveries} Dispatched
|
||||
</span>
|
||||
{orders > 0 && (
|
||||
<span className="text-[10px] text-zinc-400 font-medium">
|
||||
{Math.round((deliveries / orders) * 100)}% Fulfilled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 px-1.5 py-0.5 rounded text-[9px] font-bold uppercase ${
|
||||
str(loc.status).toLowerCase() === 'active'
|
||||
? 'text-emerald-600 bg-emerald-50 border border-emerald-100'
|
||||
: 'text-zinc-500 bg-zinc-100'
|
||||
}`}
|
||||
>
|
||||
{str(loc.status) || '—'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user