Files
daily_merchant_web/src/components/DashboardView.tsx
2026-07-27 12:09:41 +05:30

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