190 lines
7.7 KiB
TypeScript
190 lines
7.7 KiB
TypeScript
/**
|
|
* @license
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import React from 'react';
|
|
import {
|
|
Wallet,
|
|
TrendingUp,
|
|
Store,
|
|
MapPin,
|
|
Phone,
|
|
AlertTriangle,
|
|
Activity,
|
|
Clock,
|
|
ArrowUpRight,
|
|
} from 'lucide-react';
|
|
import { useFiestaLocationSummary, useFiestaTenantLocations, useFiestaRevenueSummary, useFiestaOrderSummary } from '../services/fiestaQueries';
|
|
import { FIESTA_TENANT_ID } from '../services/fiestaApi';
|
|
|
|
interface DashboardViewProps {
|
|
searchQuery: string;
|
|
isCoimbatoreView: boolean;
|
|
/** Fiesta merchant tenant to scope live store summaries to. */
|
|
tenantId?: number;
|
|
}
|
|
|
|
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, tenantId = FIESTA_TENANT_ID }: DashboardViewProps) {
|
|
// Live data — month-to-date order summary + tenant identity + store locations.
|
|
const today = new Date();
|
|
const monthStart = new Date(today);
|
|
monthStart.setDate(today.getDate() - 30); // Last 30 days to include May orders
|
|
const fromdate = ymd(monthStart);
|
|
const todate = ymd(today);
|
|
|
|
// All scoped to the signed-in merchant's tenant. Store locations come from the
|
|
// Fiesta source (the single source of truth used across the app) — it's already
|
|
// deduped and stripped of test rows, unlike the raw Hasura tenant-locations feed.
|
|
const summaryQ = useFiestaOrderSummary(tenantId, fromdate, todate);
|
|
const locationsQ = useFiestaTenantLocations(tenantId);
|
|
const revenueQ = useFiestaRevenueSummary({ tenantid: tenantId, fromdate, todate });
|
|
|
|
const s = summaryQ.data;
|
|
const tenantName = s?.tenantname || `Tenant ${tenantId}`;
|
|
|
|
// Revenue + profit come from the live invoice/financial insight. The endpoint
|
|
// returns two distinct figures (revenue and profit); we surface both rather than
|
|
// repeating one. When the tenant has no invoice records we show "—" instead of a
|
|
// misleading ₹0.
|
|
const insight = revenueQ.data as any;
|
|
const money = (v: number | null) => (v == null ? '—' : `₹${Math.round(v).toLocaleString('en-IN')}`);
|
|
const monthlyRevenue = insight ? Number(insight.grossrevenue || insight.overallrevenue || insight.revenue || 0) : null;
|
|
const monthlyProfit = insight ? Number(insight.profit || insight.netrevenue || insight.margin || 0) : null;
|
|
|
|
const locSummaryQ = useFiestaLocationSummary(tenantId, fromdate, todate);
|
|
const summaries = locSummaryQ.data ?? [];
|
|
|
|
// Region fulfillment — live month-to-date delivered ÷ total orders for the tenant.
|
|
const ordersTotal = s?.total ?? 0;
|
|
const ordersDelivered = s?.delivered ?? 0;
|
|
const regionFulfillmentPct = ordersTotal > 0 ? (ordersDelivered / ordersTotal) * 100 : null;
|
|
|
|
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}`,
|
|
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: revenueQ.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: revenueQ.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">
|
|
|
|
|
|
{/* 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 / getinvoiceinsight */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
|
|
{kpis.map((kpi) => {
|
|
const Icon = kpi.icon;
|
|
return (
|
|
<div
|
|
key={kpi.title}
|
|
className="group relative flex items-center gap-3 overflow-hidden bg-white border border-slate-200/70 rounded-xl p-3 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)]"
|
|
>
|
|
{/* Gradient accent bar */}
|
|
<span className={`absolute inset-y-0 left-0 w-1 bg-gradient-to-b ${kpi.bar}`} />
|
|
|
|
<div className={`h-10 w-10 shrink-0 rounded-lg flex items-center justify-center ring-1 group-hover:scale-110 transition-transform duration-300 ml-1 ${kpi.chip}`}>
|
|
<Icon size={18} />
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-0 flex flex-col justify-center">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-[9px] font-bold text-slate-400 tracking-widest uppercase font-sans truncate pr-2">
|
|
{kpi.title}
|
|
</p>
|
|
<ArrowUpRight size={12} className="text-slate-300 group-hover:text-purple-400 transition-colors shrink-0" />
|
|
</div>
|
|
<p className="font-sans font-extrabold text-lg leading-tight text-slate-900 tracking-tight mt-0.5 truncate">
|
|
{kpi.loading ? <span className="text-slate-300">…</span> : kpi.display}
|
|
</p>
|
|
<p className="text-[9px] text-slate-400 font-medium mt-0.5 leading-snug truncate">
|
|
{kpi.sub}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
|
|
</div>
|
|
);
|
|
}
|