e2e testing
This commit is contained in:
@@ -541,7 +541,7 @@ export default function App() {
|
||||
}
|
||||
|
||||
case 'settings':
|
||||
return <SettingsView tenantId={tenantId} />;
|
||||
return <SettingsView tenantId={tenantId} user={authUser ?? undefined} />;
|
||||
|
||||
default:
|
||||
return null;
|
||||
|
||||
@@ -35,7 +35,8 @@ 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.getFullYear(), today.getMonth(), 1);
|
||||
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);
|
||||
|
||||
@@ -54,10 +55,10 @@ export default function DashboardView({ searchQuery, tenantId = FIESTA_TENANT_ID
|
||||
// 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 = insightQ.data;
|
||||
const insight = insightQ.data as any;
|
||||
const money = (v: number | null) => (v == null ? '—' : `₹${Math.round(v).toLocaleString('en-IN')}`);
|
||||
const monthlyRevenue = insight ? insight.revenue : null;
|
||||
const monthlyProfit = insight ? insight.profit : null;
|
||||
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);
|
||||
const summaries = locSummaryQ.data ?? [];
|
||||
|
||||
@@ -15,10 +15,11 @@
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { TrendingUp, Clock, CheckCircle2, IndianRupee, Bike, Truck, Calendar, Store } from 'lucide-react';
|
||||
import { useFiestaLocationSummary, useFiestaFleetSummary } from '../services/fiestaQueries';
|
||||
import { useFiestaLocationSummary, useFiestaFleetSummary, useFiestaOrderSummary, useFiestaAllOrders, useFiestaRevenueSummary } from '../services/fiestaQueries';
|
||||
import { ResponsiveContainer, AreaChart, Area, XAxis, YAxis, Tooltip as RechartsTooltip, CartesianGrid } from 'recharts';
|
||||
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
|
||||
import {
|
||||
GradientHeader, KpiStrip, Pill, StatusChip, MetricPill, FilterBar, TH_STYLE,
|
||||
GradientHeader, KpiStrip, Pill, StatusChip, MetricPill, FilterBar, TH_STYLE, TableShell,
|
||||
DELIVERY_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT, tint, soft, edge, ring,
|
||||
} from './consoleUi';
|
||||
|
||||
@@ -28,9 +29,9 @@ const TABS: Array<{ key: ReportTab; label: string; icon: typeof TrendingUp }> =
|
||||
{ key: 'riders-summary', label: 'Riders Summary', icon: Bike },
|
||||
];
|
||||
|
||||
interface DeliveryReportsViewProps { searchQuery?: string; tenantId?: number; }
|
||||
interface DeliveryReportsViewProps { searchQuery?: string; tenantId?: number; locationid?: number; }
|
||||
|
||||
export default function DeliveryReportsView({ searchQuery = '', tenantId = FIESTA_TENANT_ID }: DeliveryReportsViewProps) {
|
||||
export default function DeliveryReportsView({ searchQuery = '', tenantId = FIESTA_TENANT_ID, locationid }: DeliveryReportsViewProps) {
|
||||
const today = new Date();
|
||||
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
const [fromdate, setFromdate] = useState<string>(ymd(monthStart));
|
||||
@@ -46,6 +47,7 @@ export default function DeliveryReportsView({ searchQuery = '', tenantId = FIEST
|
||||
];
|
||||
const activePreset = presets.find((p) => p.from === fromdate && p.to === todate)?.key ?? 'custom';
|
||||
|
||||
|
||||
return (
|
||||
<div className="animate-in fade-in duration-300">
|
||||
<GradientHeader title="Reports" subtitle="Operational analytics across outlets, riders, and the full order lifecycle." />
|
||||
@@ -81,66 +83,205 @@ export default function DeliveryReportsView({ searchQuery = '', tenantId = FIEST
|
||||
</div>
|
||||
</FilterBar>
|
||||
|
||||
{tab === 'orders-summary' && <OrdersSummaryReport tenantId={tenantId} />}
|
||||
{tab === 'riders-summary' && <RidersSummaryReport fromdate={fromdate} todate={todate} tenantId={tenantId} />}
|
||||
|
||||
{tab === 'orders-summary' && <OrdersSummaryReport tenantId={tenantId} locationid={locationid} fromdate={fromdate} todate={todate} />}
|
||||
{tab === 'riders-summary' && <RidersSummaryReport fromdate={fromdate} todate={todate} tenantId={tenantId} locationid={locationid} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Cnt = ({ n, color }: { n: number; color: string }) => (n > 0 ? <MetricPill color={color} minWidth={34}>{n.toLocaleString('en-IN')}</MetricPill> : <span style={{ color: TEXT_3, fontWeight: 700 }}>0</span>);
|
||||
|
||||
function TableShell({ minWidth, head, children, footer }: { minWidth: number; head: string[]; children: React.ReactNode; footer?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="bg-white border rounded-2xl overflow-hidden" style={{ borderColor: BORDER }}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full" style={{ minWidth }}>
|
||||
<thead><tr>{head.map((h, i) => (<th key={i} className={`px-3 py-2.5 ${i < 2 ? 'text-left' : 'text-right'}`} style={TH_STYLE}>{h}</th>))}</tr></thead>
|
||||
<tbody>{children}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const Cnt = ({ n, color }: { n: number; color: string }) => (
|
||||
n > 0 ? <span className="inline-flex items-center justify-center px-2 py-0.5 rounded-md font-bold text-xs" style={{ backgroundColor: `${color}15`, color }}>{n.toLocaleString('en-IN')}</span> : <span className="font-bold text-slate-300 text-xs">0</span>
|
||||
);
|
||||
|
||||
// ── Orders Summary (per outlet) ──────────────────────────────────────────────────
|
||||
function OrdersSummaryReport({ tenantId }: { tenantId: number }) {
|
||||
function OrdersSummaryReport({ tenantId, locationid, fromdate, todate }: { tenantId: number; locationid?: number; fromdate: string; todate: string; }) {
|
||||
const [metric, setMetric] = useState<'revenue' | 'orders'>('revenue');
|
||||
|
||||
const q = useFiestaLocationSummary(tenantId);
|
||||
const rows = q.data ?? [];
|
||||
const ordersQ = useFiestaAllOrders({ tenantid: tenantId, fromdate, todate, locationid });
|
||||
const revenueQ = useFiestaRevenueSummary({ tenantid: tenantId, locationid, fromdate, todate });
|
||||
|
||||
const rows = (q.data ?? []).filter(r => locationid ? r.locationid === locationid : true);
|
||||
const totals = rows.reduce((a, r) => ({ total: a.total + r.total, pending: a.pending + r.pending, delivered: a.delivered + r.delivered, cancelled: a.cancelled + r.cancelled }), { total: 0, pending: 0, delivered: 0, cancelled: 0 });
|
||||
const kpis = [
|
||||
{ label: 'Total Orders', value: totals.total.toLocaleString('en-IN'), color: BRAND, icon: <TrendingUp size={20} /> },
|
||||
{ label: 'Pending', value: totals.pending.toLocaleString('en-IN'), color: '#f59e0b', icon: <Clock size={20} /> },
|
||||
{ label: 'Delivered', value: totals.delivered.toLocaleString('en-IN'), color: '#10b981', icon: <CheckCircle2 size={20} /> },
|
||||
{ label: 'Outlets', value: rows.length.toLocaleString('en-IN'), color: '#0ea5e9', icon: <Store size={20} /> },
|
||||
];
|
||||
|
||||
// Compute trend data
|
||||
const chartData = useMemo(() => {
|
||||
const map = new Map<string, { date: string; orders: number; revenue: number }>();
|
||||
for (const r of (ordersQ.data ?? [])) {
|
||||
const dateVal = fstr(r.orderdate) || fstr(r.deliverydate);
|
||||
if (!dateVal) continue;
|
||||
const key = dateVal.split('T')[0];
|
||||
|
||||
const ex = map.get(key) || { date: key, orders: 0, revenue: 0 };
|
||||
ex.orders += 1;
|
||||
const amt = fnum(r.ordervalue) || fnum(r.orderamount) || fnum(r.deliveryamt);
|
||||
ex.revenue += amt;
|
||||
map.set(key, ex);
|
||||
}
|
||||
return Array.from(map.values()).sort((a, b) => a.date.localeCompare(b.date));
|
||||
}, [ordersQ.data]);
|
||||
|
||||
const formatLabel = (key: string) => {
|
||||
const d = new Date(key);
|
||||
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||
return `${d.getDate()} ${months[d.getMonth()]}`;
|
||||
};
|
||||
|
||||
const totalRevenue = revenueQ.data?.grossrevenue ?? 0;
|
||||
const locationRevenueMap = useMemo(() => {
|
||||
const map = new Map<number, number>();
|
||||
for (const r of (ordersQ.data ?? [])) {
|
||||
const loc = fnum(r.locationid) || fnum(r.applocationid);
|
||||
const amt = fnum(r.ordervalue) || fnum(r.orderamount) || fnum(r.deliveryamt) || fnum(r.collectionamt);
|
||||
if (loc && amt) {
|
||||
map.set(loc, (map.get(loc) || 0) + amt);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [ordersQ.data]);
|
||||
|
||||
const metricColor = metric === 'revenue' ? '#4f46e5' : '#0ea5e9';
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<KpiStrip items={kpis} loading={q.isLoading} />
|
||||
<TableShell minWidth={820} head={['#', 'Outlet', 'All', 'Created', 'Pending', 'Processing', 'Delivered', 'Cancelled']}
|
||||
footer={rows.length > 0 ? <TotalBar chips={[{ label: `${totals.total} orders`, color: BRAND }, { label: `${totals.delivered} delivered`, color: '#10b981' }, { label: `${totals.pending} pending`, color: '#f59e0b' }]} /> : undefined}>
|
||||
{q.isLoading ? <tr><td colSpan={8} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>Loading outlet summary…</td></tr>
|
||||
: rows.length === 0 ? <tr><td colSpan={8} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>No outlet data available.</td></tr>
|
||||
: rows.map((r, i) => (
|
||||
<tr key={r.locationid || i} className="transition-colors" style={{ borderBottom: `1px solid ${DIVIDER}` }} onMouseEnter={(e) => (e.currentTarget.style.background = SURFACE_ALT)} onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}>
|
||||
<td className="px-3 py-2.5 font-mono" style={{ color: TEXT_3 }}>{i + 1}</td>
|
||||
<td className="px-3 py-2.5 font-extrabold text-[13px]" style={{ color: TEXT }}>{r.locationname || `Location ${r.locationid}`}</td>
|
||||
<td className="px-3 py-2.5 text-right font-extrabold font-mono" style={{ color: TEXT }}>{r.total.toLocaleString('en-IN')}</td>
|
||||
<td className="px-3 py-2.5 text-right"><Cnt n={r.created} color="#0ea5e9" /></td>
|
||||
<td className="px-3 py-2.5 text-right"><Cnt n={r.pending} color="#f59e0b" /></td>
|
||||
<td className="px-3 py-2.5 text-right"><Cnt n={r.processing} color="#6366f1" /></td>
|
||||
<td className="px-3 py-2.5 text-right"><Cnt n={r.delivered} color="#10b981" /></td>
|
||||
<td className="px-3 py-2.5 text-right"><Cnt n={r.cancelled} color="#ef4444" /></td>
|
||||
</tr>
|
||||
))}
|
||||
</TableShell>
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<button onClick={() => setMetric('revenue')} className={`text-left p-5 bg-white rounded-2xl border transition-all duration-200 cursor-pointer flex items-center gap-4 ${metric === 'revenue' ? 'border-indigo-500 shadow-md ring-1 ring-indigo-500' : 'border-slate-200 shadow-sm hover:shadow-md'}`}>
|
||||
<div className={`p-3 rounded-xl ${metric === 'revenue' ? 'bg-indigo-100 text-indigo-600' : 'bg-slate-50 text-slate-400'}`}><IndianRupee size={20} /></div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-500">Revenue</p>
|
||||
<p className="text-2xl font-bold tracking-tight text-slate-900 mt-0.5">₹{totalRevenue.toLocaleString('en-IN')}</p>
|
||||
</div>
|
||||
</button>
|
||||
<button onClick={() => setMetric('orders')} className={`text-left p-5 bg-white rounded-2xl border transition-all duration-200 cursor-pointer flex items-center gap-4 ${metric === 'orders' ? 'border-sky-500 shadow-md ring-1 ring-sky-500' : 'border-slate-200 shadow-sm hover:shadow-md'}`}>
|
||||
<div className={`p-3 rounded-xl ${metric === 'orders' ? 'bg-sky-100 text-sky-600' : 'bg-slate-50 text-slate-400'}`}><TrendingUp size={20} /></div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-500">Total Orders</p>
|
||||
<p className="text-2xl font-bold tracking-tight text-slate-900 mt-0.5">{totals.total.toLocaleString('en-IN')}</p>
|
||||
</div>
|
||||
</button>
|
||||
<div className="p-5 bg-white rounded-2xl border border-slate-200 shadow-sm flex items-center gap-4">
|
||||
<div className="p-3 rounded-xl bg-amber-50 text-amber-600"><Clock size={20} /></div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-500">Pending</p>
|
||||
<p className="text-2xl font-bold tracking-tight text-slate-900 mt-0.5">{totals.pending.toLocaleString('en-IN')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 bg-white rounded-2xl border border-slate-200 shadow-sm flex items-center gap-4">
|
||||
<div className="p-3 rounded-xl bg-emerald-50 text-emerald-600"><CheckCircle2 size={20} /></div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-500">Delivered</p>
|
||||
<p className="text-2xl font-bold tracking-tight text-slate-900 mt-0.5">{totals.delivered.toLocaleString('en-IN')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
<tr>
|
||||
<th className="px-5 py-4 w-12">#</th>
|
||||
<th className="px-5 py-4">Outlet</th>
|
||||
<th className="px-5 py-4 text-right">Revenue</th>
|
||||
<th className="px-5 py-4 text-right">All</th>
|
||||
<th className="px-5 py-4 text-right">Created</th>
|
||||
<th className="px-5 py-4 text-right">Pending</th>
|
||||
<th className="px-5 py-4 text-right">Processing</th>
|
||||
<th className="px-5 py-4 text-right">Delivered</th>
|
||||
<th className="px-5 py-4 text-right">Cancelled</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{q.isLoading || revenueQ.isLoading ? <tr><td colSpan={9} className="px-5 py-12 text-center text-slate-500 font-medium">Loading outlet summary…</td></tr>
|
||||
: rows.length === 0 ? <tr><td colSpan={9} className="px-5 py-12 text-center text-slate-500 font-medium">No outlet data available.</td></tr>
|
||||
: rows.map((r, i) => (
|
||||
<tr key={r.locationid || i} className="hover:bg-slate-50 transition-colors">
|
||||
<td className="px-5 py-3 text-slate-400 font-medium">{i + 1}</td>
|
||||
<td className="px-5 py-3 font-semibold text-slate-900">{r.locationname || `Location ${r.locationid}`}</td>
|
||||
<td className="px-5 py-3 text-right font-bold text-indigo-600">₹{(locationRevenueMap.get(fnum(r.locationid)) ?? 0).toLocaleString('en-IN')}</td>
|
||||
<td className="px-5 py-3 text-right font-bold text-slate-900">{r.total.toLocaleString('en-IN')}</td>
|
||||
<td className="px-5 py-3 text-right"><Cnt n={r.created} color="#0ea5e9" /></td>
|
||||
<td className="px-5 py-3 text-right"><Cnt n={r.pending} color="#f59e0b" /></td>
|
||||
<td className="px-5 py-3 text-right"><Cnt n={r.processing} color="#6366f1" /></td>
|
||||
<td className="px-5 py-3 text-right"><Cnt n={r.delivered} color="#10b981" /></td>
|
||||
<td className="px-5 py-3 text-right"><Cnt n={r.cancelled} color="#ef4444" /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{rows.length > 0 && (
|
||||
<div className="bg-slate-50 border-t border-slate-200 px-5 py-3 flex items-center gap-4 text-sm font-medium">
|
||||
<span className="text-slate-500">Totals:</span>
|
||||
<span className="text-indigo-600 font-bold">₹{totalRevenue.toLocaleString('en-IN')}</span>
|
||||
<span className="text-slate-900 font-bold">{totals.total} orders</span>
|
||||
<span className="text-emerald-600 font-bold">{totals.delivered} delivered</span>
|
||||
<span className="text-amber-600 font-bold">{totals.pending} pending</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Trend Chart */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<TrendingUp size={20} className="text-indigo-500" />
|
||||
<h3 className="text-base font-bold text-slate-900 tracking-tight">Trend Analysis</h3>
|
||||
</div>
|
||||
|
||||
<div className="w-full h-80">
|
||||
{ordersQ.isLoading ? (
|
||||
<div className="w-full h-full flex items-center justify-center text-sm font-medium text-slate-500">Loading trend data...</div>
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="w-full h-full flex items-center justify-center text-sm font-medium text-slate-500">No trend data for this period.</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="colorGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={metricColor} stopOpacity={0.2} />
|
||||
<stop offset="95%" stopColor={metricColor} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="4 4" vertical={false} stroke="#e2e8f0" />
|
||||
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: '#64748b', fontWeight: 500 }} dy={10}
|
||||
tickFormatter={formatLabel} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: '#64748b', fontWeight: 500 }} dx={-10} tickFormatter={(v) => metric === 'revenue' ? `₹${v.toLocaleString('en-IN')}` : v.toLocaleString('en-IN')} />
|
||||
<RechartsTooltip
|
||||
cursor={{ stroke: metricColor, strokeWidth: 1, strokeDasharray: '4 4' }}
|
||||
content={({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-lg">
|
||||
<p className="text-xs font-bold text-slate-400 uppercase tracking-wider mb-2">{formatLabel(label)}</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-slate-600 capitalize">
|
||||
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: metricColor }}></span>
|
||||
{metric}
|
||||
</span>
|
||||
<span className="text-lg font-bold text-slate-900">
|
||||
{metric === 'revenue' ? `₹${payload[0].value.toLocaleString('en-IN')}` : payload[0].value.toLocaleString('en-IN')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Area type="monotone" dataKey={metric} stroke={metricColor} strokeWidth={2.5} fillOpacity={1} fill="url(#colorGradient)" activeDot={{ r: 6, strokeWidth: 2, stroke: '#fff', fill: metricColor }} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Riders Summary (per rider) ───────────────────────────────────────────────────
|
||||
function RidersSummaryReport({ fromdate, todate, tenantId }: { fromdate: string; todate: string; tenantId: number }) {
|
||||
const q = useFiestaFleetSummary({ tenantid: tenantId, fromdate, todate });
|
||||
function RidersSummaryReport({ fromdate, todate, tenantId, locationid }: { fromdate: string; todate: string; tenantId: number; locationid?: number }) {
|
||||
const q = useFiestaFleetSummary({ tenantid: tenantId, fromdate, todate, applocationid: locationid });
|
||||
const rows = q.data ?? [];
|
||||
const mapped = rows.map((r) => ({
|
||||
name: fstr(r.fullname) || `${fstr(r.firstname)} ${fstr(r.lastname)}`.trim() || fstr(r.username) || `Rider ${fstr(r.userid)}`,
|
||||
|
||||
@@ -90,7 +90,6 @@ function pickupLatLon(r: Row): [number, number] | null {
|
||||
type ViewMode = 'kitchens' | 'zones' | 'riders';
|
||||
const VIEW_TABS: Array<{ id: ViewMode; label: string; icon: typeof MapIcon }> = [
|
||||
{ id: 'kitchens', label: 'By Location', icon: MapPin },
|
||||
{ id: 'zones', label: 'By Zone', icon: MapIcon },
|
||||
{ id: 'riders', label: 'By Rider', icon: Bike },
|
||||
];
|
||||
|
||||
@@ -294,7 +293,6 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{headerTabs}
|
||||
</div>
|
||||
|
||||
<div className="hdr-stats">
|
||||
@@ -359,6 +357,11 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{headerTabs && (
|
||||
<div style={{ marginLeft: 8 }}>
|
||||
{headerTabs}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Body ── */}
|
||||
|
||||
@@ -58,6 +58,9 @@ export default function OrdersDeliveriesView({
|
||||
}: OrdersDeliveriesViewProps) {
|
||||
const todayStr = ymd(new Date());
|
||||
|
||||
const [fromdate, setFromdate] = useState(todayStr);
|
||||
const [todate, setTodate] = useState(todayStr);
|
||||
|
||||
const [status, setStatus] = useState<StatusKey>('all');
|
||||
const [pageno, setPageno] = useState(1);
|
||||
const [localSearch, setLocalSearch] = useState('');
|
||||
@@ -75,8 +78,8 @@ export default function OrdersDeliveriesView({
|
||||
}, []);
|
||||
|
||||
// ── Queries ──────────────────────────────────────────────────────────────────
|
||||
const allOrdersQ = useFiestaAllOrders({ tenantid: tenantId, fromdate: todayStr, todate: todayStr, locationid });
|
||||
const summaryQ = useFiestaDeliverySummary({ tenantid: tenantId, fromdate: todayStr, todate: todayStr, locationid });
|
||||
const allOrdersQ = useFiestaAllOrders({ tenantid: tenantId, fromdate, todate, locationid });
|
||||
const summaryQ = useFiestaDeliverySummary({ tenantid: tenantId, fromdate, todate, locationid });
|
||||
const ridersQ = useFiestaRiders({ tenantid: tenantId });
|
||||
|
||||
const allRows = allOrdersQ.data ?? [];
|
||||
@@ -100,7 +103,11 @@ export default function OrdersDeliveriesView({
|
||||
const rows = useMemo(() => {
|
||||
const term = (localSearch || searchQuery).toLowerCase();
|
||||
return allRows.filter((r) => {
|
||||
if (locationid && fnum(r.locationid) !== locationid) return false;
|
||||
if (locationid) {
|
||||
const rLoc = fnum(r.locationid);
|
||||
const rApp = fnum(r.applocationid);
|
||||
if (rLoc !== locationid && rApp !== locationid) return false;
|
||||
}
|
||||
if (status !== 'all' && fstr(r.orderstatus).toLowerCase() !== status) return false;
|
||||
if (!term) return true;
|
||||
return [
|
||||
@@ -162,7 +169,7 @@ export default function OrdersDeliveriesView({
|
||||
const blob = new Blob([[headers.join(','), ...lines].join('\n')], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a'); a.href = url;
|
||||
a.download = `Orders_${status}_${todayStr}.csv`; a.click();
|
||||
a.download = `Orders_${status}_${fromdate}_to_${todate}.csv`; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
@@ -179,10 +186,17 @@ export default function OrdersDeliveriesView({
|
||||
: <LiveStatus state="live" label={`Live · ${total.toLocaleString('en-IN')} orders in range · ${activeFleet} riders on duty`} />
|
||||
}
|
||||
right={
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full font-extrabold"
|
||||
style={{ padding: '6px 12px', fontSize: 12, background: tint(BRAND), border: `1.5px solid ${edge(BRAND)}`, color: BRAND }}>
|
||||
<MapPin size={13} /> {locationid ? `Location ${locationid}` : 'All Locations'}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input type="date" value={fromdate} max={todate} onChange={(e) => setFromdate(e.target.value)} className="rounded-full outline-none font-semibold text-xs transition-colors" style={{ padding: '6px 12px', border: `1.5px solid ${edge(BRAND)}`, background: tint(BRAND), color: BRAND }} />
|
||||
<span className="text-zinc-400 font-bold px-1 text-xs">—</span>
|
||||
<input type="date" value={todate} min={fromdate} max={todayStr} onChange={(e) => setTodate(e.target.value)} className="rounded-full outline-none font-semibold text-xs transition-colors" style={{ padding: '6px 12px', border: `1.5px solid ${edge(BRAND)}`, background: tint(BRAND), color: BRAND }} />
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full font-extrabold"
|
||||
style={{ padding: '6px 12px', fontSize: 12, background: tint(BRAND), border: `1.5px solid ${edge(BRAND)}`, color: BRAND }}>
|
||||
<MapPin size={13} /> {locationid ? `Location ${locationid}` : 'All Locations'}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -213,8 +213,10 @@ export default function OrdersView({ searchQuery = '', locationid, tenantId = FI
|
||||
|
||||
// ── Selection helpers ───────────────────────────────────────────────────────
|
||||
const rowKey = (r: Row) => fstr(r.orderheaderid) || fstr(r.orderid);
|
||||
const pageKeys = rows.map(rowKey);
|
||||
const allSelected = pageKeys.length > 0 && pageKeys.every((k) => selected.has(k));
|
||||
const assignableRows = rows.filter((r) => fstr(r.orderstatus).toLowerCase() === 'created');
|
||||
const assignableKeys = assignableRows.map(rowKey);
|
||||
const allSelected = assignableKeys.length > 0 && assignableKeys.every((k) => selected.has(k));
|
||||
|
||||
const toggleRow = (k: string) =>
|
||||
setSelected((prev) => {
|
||||
const n = new Set(prev);
|
||||
@@ -222,11 +224,12 @@ export default function OrdersView({ searchQuery = '', locationid, tenantId = FI
|
||||
else n.add(k);
|
||||
return n;
|
||||
});
|
||||
|
||||
const toggleAll = () =>
|
||||
setSelected((prev) => {
|
||||
const n = new Set(prev);
|
||||
if (allSelected) pageKeys.forEach((k) => n.delete(k));
|
||||
else pageKeys.forEach((k) => n.add(k));
|
||||
if (allSelected) assignableKeys.forEach((k) => n.delete(k));
|
||||
else assignableKeys.forEach((k) => n.add(k));
|
||||
return n;
|
||||
});
|
||||
|
||||
@@ -383,7 +386,7 @@ export default function OrdersView({ searchQuery = '', locationid, tenantId = FI
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-3 py-2.5 text-left" style={TH_STYLE}>
|
||||
<input type="checkbox" checked={allSelected} onChange={toggleAll} disabled={rows.length === 0} aria-label="Select all orders" style={{ accentColor: BRAND, cursor: 'pointer', width: 15, height: 15 }} />
|
||||
<input type="checkbox" checked={allSelected} onChange={toggleAll} disabled={assignableRows.length === 0} aria-label="Select all assignable orders" style={{ accentColor: BRAND, cursor: 'pointer', width: 15, height: 15 }} />
|
||||
</th>
|
||||
{['#', 'Order', 'Branch', 'Pickup', 'Drop', 'Qty', 'COD', 'KMs', 'Charges', 'Status', ''].map((h, i) => (
|
||||
<th key={i} className="px-3 py-2.5 text-left" style={TH_STYLE}>{h}</th>
|
||||
@@ -406,7 +409,11 @@ export default function OrdersView({ searchQuery = '', locationid, tenantId = FI
|
||||
<tr key={fstr(r.orderid) || i} className="transition-colors" style={{ borderBottom: `1px solid ${DIVIDER_C}`, background: selected.has(rowKey(r)) ? tint(BRAND) : 'transparent' }}
|
||||
onMouseEnter={(e) => { if (!selected.has(rowKey(r))) e.currentTarget.style.background = SURFACE_ALT; }} onMouseLeave={(e) => { e.currentTarget.style.background = selected.has(rowKey(r)) ? tint(BRAND) : 'transparent'; }}>
|
||||
<td className="px-3 py-2.5">
|
||||
<input type="checkbox" checked={selected.has(rowKey(r))} onChange={() => toggleRow(rowKey(r))} aria-label="Select order" style={{ accentColor: BRAND, cursor: 'pointer', width: 15, height: 15 }} />
|
||||
{st === 'created' ? (
|
||||
<input type="checkbox" checked={selected.has(rowKey(r))} onChange={() => toggleRow(rowKey(r))} aria-label="Select order" style={{ accentColor: BRAND, cursor: 'pointer', width: 15, height: 15 }} />
|
||||
) : (
|
||||
<input type="checkbox" disabled aria-label="Order cannot be assigned" style={{ width: 15, height: 15, opacity: 0.3 }} title="Only 'created' orders can be assigned" />
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono" style={{ color: TEXT_3 }}>{(pageno - 1) * PAGE_SIZE + i + 1}</td>
|
||||
<td className="px-3 py-2.5">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
useFiestaLocationSummary,
|
||||
useFiestaOrderInsight,
|
||||
useFiestaStockStatement,
|
||||
useFiestaRevenueSummary,
|
||||
useFiestaTimeSeries,
|
||||
} 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';
|
||||
@@ -51,6 +53,7 @@ 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 [chartTimeframe, setChartTimeframe] = useState<'day' | 'week' | 'month'>('week');
|
||||
const [expandedProductId, setExpandedProductId] = useState<string | null>(null);
|
||||
const [exportingFormat, setExportingFormat] = useState<'PDF' | 'CSV' | null>(null);
|
||||
const [exportProgress, setExportProgress] = useState(0);
|
||||
@@ -92,6 +95,8 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
const prevSummaryQ = useFiestaOrderSummary(tenantId, ymd(prevStart), ymd(prevEnd));
|
||||
const locSummaryQ = useFiestaLocationSummary(tenantId);
|
||||
const insightQ = useFiestaOrderInsight(tenantId);
|
||||
const revSummaryQ = useFiestaRevenueSummary({ tenantid: tenantId, fromdate: ymd(yearStart), todate });
|
||||
const prevRevSummaryQ = useFiestaRevenueSummary({ tenantid: tenantId, fromdate: ymd(prevStart), todate: ymd(prevEnd) });
|
||||
const stockQ = useFiestaStockStatement({
|
||||
tenantid: tenantId,
|
||||
locationid: FIESTA_PRIMARY_LOCATION_ID,
|
||||
@@ -111,6 +116,11 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
};
|
||||
const ordersDelta = s && prevS ? pctChange(s.total, prevS.total) : null;
|
||||
const cancelledDelta = s && prevS ? pctChange(s.cancelled, prevS.cancelled) : null;
|
||||
|
||||
const revS = revSummaryQ.data;
|
||||
const prevRevS = prevRevSummaryQ.data;
|
||||
const revenueDelta = revS && prevRevS ? pctChange(revS.grossrevenue, prevRevS.grossrevenue) : null;
|
||||
|
||||
const fmtDelta = (d: number) => `${d >= 0 ? '+' : ''}${d.toFixed(1)}%`;
|
||||
|
||||
// Dynamic sparkline generator helper
|
||||
@@ -185,16 +195,15 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
awaiting: false,
|
||||
},
|
||||
{
|
||||
// Revenue: no revenue API ([R1]) — render AwaitingApi instead of a value.
|
||||
id: 'revenue' as const,
|
||||
title: 'Revenue',
|
||||
value: '',
|
||||
trend: null,
|
||||
status: '',
|
||||
isPositive: true,
|
||||
value: `₹${(revS?.grossrevenue ?? 0).toLocaleString('en-IN')}`,
|
||||
trend: revenueDelta !== null ? fmtDelta(revenueDelta) : null,
|
||||
status: `₹${(revS?.avgordervalue ?? 0).toLocaleString('en-IN')} avg. order`,
|
||||
isPositive: revenueDelta === null ? true : revenueDelta >= 0,
|
||||
spark: [20, 30, 25, 45, 40, 55, 50, 68],
|
||||
color: 'emerald',
|
||||
awaiting: true,
|
||||
awaiting: false,
|
||||
},
|
||||
{
|
||||
id: 'cancelled' as const,
|
||||
@@ -331,6 +340,55 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
}, 120);
|
||||
};
|
||||
|
||||
// Real chart data from API
|
||||
const tsGranularity = chartTimeframe === 'month' ? 'month' : 'day';
|
||||
const tsFromDate = useMemo(() => {
|
||||
const d = new Date();
|
||||
if (chartTimeframe === 'day') d.setDate(d.getDate() - 60); // last 60 days (covers May)
|
||||
else if (chartTimeframe === 'week') d.setDate(d.getDate() - 180); // last 6 months
|
||||
else d.setFullYear(d.getFullYear() - 1); // last year
|
||||
return ymd(d);
|
||||
}, [chartTimeframe]);
|
||||
|
||||
const tsQ = useFiestaTimeSeries({
|
||||
tenantid: tenantId,
|
||||
granularity: tsGranularity,
|
||||
fromdate: tsFromDate,
|
||||
todate: todate,
|
||||
});
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
const data = tsQ.data || [];
|
||||
if (data.length === 0) return [{ label: 'No Data', value: 0 }];
|
||||
|
||||
return data.map((d: any) => {
|
||||
let labelStr = d.label || d.date || d.createdat || "Unknown";
|
||||
if (tsGranularity === 'day' && labelStr !== "Unknown") {
|
||||
const dt = new Date(labelStr);
|
||||
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||
labelStr = `${dt.getDate()} ${months[dt.getMonth()]}`;
|
||||
}
|
||||
|
||||
let val = 0;
|
||||
if (chartMetric === 'skus') {
|
||||
val = Number(d.activeskus || d.skus || 0);
|
||||
} else if (chartMetric === 'orders') {
|
||||
val = Number(d.orders || d.totalorders || d.total || 0);
|
||||
} else if (chartMetric === 'revenue') {
|
||||
val = Number(d.revenue || d.grossrevenue || d.overallrevenue || 0);
|
||||
} else {
|
||||
val = Number(d[chartMetric]) || 0;
|
||||
}
|
||||
|
||||
return {
|
||||
label: labelStr,
|
||||
value: val
|
||||
};
|
||||
});
|
||||
}, [tsQ.data, chartMetric, tsGranularity]);
|
||||
|
||||
const maxChartVal = useMemo(() => Math.max(...chartData.map(d => d.value), 1), [chartData]);
|
||||
|
||||
return (
|
||||
<div className="space-y-lg animate-in fade-in duration-500 font-sans relative">
|
||||
|
||||
@@ -523,7 +581,10 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<AwaitingApi label="Gross Revenue" api="[R1]" compact />
|
||||
<h3 className="text-xl font-extrabold tracking-tight font-mono">
|
||||
₹{(revS?.grossrevenue ?? 0).toLocaleString('en-IN')}
|
||||
</h3>
|
||||
<p className="text-[10px] text-slate-400 font-semibold mt-1">₹{(revS?.netrevenue ?? 0).toLocaleString('en-IN')} net</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -579,7 +640,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
<button
|
||||
key={kpi.id}
|
||||
onClick={() => setChartMetric(kpi.id)}
|
||||
className={`border backdrop-blur-md rounded-3xl p-lg flex flex-col justify-between hover:shadow-lg transition-all duration-300 hover:-translate-y-1.5 text-left cursor-pointer outline-none relative overflow-hidden min-h-[160px] group ${activeStyles}`}
|
||||
className={`border backdrop-blur-md rounded-2xl p-3 flex flex-col justify-between hover:shadow-lg transition-all duration-300 hover:-translate-y-1.5 text-left cursor-pointer outline-none relative overflow-hidden group ${activeStyles}`}
|
||||
>
|
||||
{/* Subtle top glowing line when active */}
|
||||
{isActive && (
|
||||
@@ -591,19 +652,19 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
<span className="text-[10px] font-sans font-bold text-zinc-400 uppercase tracking-widest block">
|
||||
{kpi.title}
|
||||
</span>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center transition-transform duration-300 group-hover:scale-110 ${iconBg} ${iconTextColor}`}>
|
||||
<IconComponent size={14} />
|
||||
<div className={`w-7 h-7 rounded-full flex items-center justify-center transition-transform duration-300 group-hover:scale-110 ${iconBg} ${iconTextColor}`}>
|
||||
<IconComponent size={13} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Metric Value and Trend Badge */}
|
||||
{kpi.awaiting ? (
|
||||
<div className="mt-3">
|
||||
<div className="mt-1">
|
||||
<AwaitingApi label="Revenue" api="[R1]" compact />
|
||||
</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">
|
||||
<div className="mt-1.5 flex items-baseline gap-2">
|
||||
<div className="font-sans font-black text-slate-900 text-2xl tracking-tight leading-none">
|
||||
{kpi.value}
|
||||
</div>
|
||||
{kpi.trend && (
|
||||
@@ -617,7 +678,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
)}
|
||||
|
||||
{/* Bottom Sparkline & Subtext segment */}
|
||||
<div className="flex items-center justify-between mt-auto pt-3 w-full border-t border-[#f1f5f9]">
|
||||
<div className="flex items-center justify-between mt-auto pt-2 w-full border-t border-[#f1f5f9]">
|
||||
<span className="text-[10px] text-zinc-550 font-bold uppercase tracking-wider">{kpi.status}</span>
|
||||
|
||||
{/* KPI Mini Sparkline Chart */}
|
||||
@@ -657,19 +718,94 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<span className="text-[10px] font-mono text-zinc-555 font-bold bg-zinc-50 border border-zinc-200/60 px-3 py-1 rounded-xl flex items-center gap-1.5">
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex bg-slate-100/50 p-1 rounded-xl border border-slate-200/60 hidden sm:flex">
|
||||
{(['day', 'week', 'month'] as const).map((tf) => (
|
||||
<button
|
||||
key={tf}
|
||||
onClick={() => setChartTimeframe(tf)}
|
||||
className={`px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-all ${
|
||||
chartTimeframe === tf
|
||||
? 'bg-white text-purple-700 shadow-sm border border-purple-100'
|
||||
: 'text-slate-500 hover:text-slate-700 hover:bg-slate-200/50'
|
||||
}`}
|
||||
>
|
||||
{tf}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<span className="text-[10px] font-mono text-zinc-555 font-bold bg-zinc-50 border border-zinc-200/60 px-3 py-1.5 rounded-xl flex items-center gap-1.5 ml-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-current animate-pulse" style={{ color: theme.activeLine }} />
|
||||
<span>Live Sync</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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" />
|
||||
{/* Plotted Area */}
|
||||
<div className="relative h-64 select-none w-full flex items-end pt-8 pb-4 px-2">
|
||||
<svg className="absolute inset-0 w-full h-full preserve-3d" preserveAspectRatio="none">
|
||||
<defs>
|
||||
<linearGradient id="chartGradient" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor={theme.activeLine} stopOpacity={0.4} />
|
||||
<stop offset="100%" stopColor={theme.activeLine} stopOpacity={0.01} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d={`M 0,100 ` + chartData.map((d, i) => {
|
||||
const x = (i / (chartData.length - 1)) * 100;
|
||||
const y = 100 - ((d.value / maxChartVal) * 80);
|
||||
return `L ${x},${y} `;
|
||||
}).join('') + `L 100,100 Z`}
|
||||
fill="url(#chartGradient)"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<path
|
||||
d={chartData.map((d, i) => {
|
||||
const x = (i / (chartData.length - 1)) * 100;
|
||||
const y = 100 - ((d.value / maxChartVal) * 80);
|
||||
return `${i === 0 ? 'M' : 'L'} ${x},${y}`;
|
||||
}).join(' ')}
|
||||
fill="none"
|
||||
stroke={theme.activeLine}
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
className="drop-shadow-[0_4px_6px_rgba(168,85,247,0.4)]"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div className="absolute inset-0 flex justify-between items-end pb-4 px-2">
|
||||
{chartData.map((d, i) => (
|
||||
<div key={i} className="group relative flex flex-col items-center flex-1 h-full justify-end cursor-crosshair">
|
||||
{/* Hover vertical line */}
|
||||
<div className="absolute top-0 bottom-6 w-px bg-slate-200/0 group-hover:bg-slate-300/50 transition-colors" />
|
||||
|
||||
{/* Invisible hit area */}
|
||||
<div className="absolute inset-0 w-full h-full z-10" />
|
||||
|
||||
{/* Tooltip */}
|
||||
<div className="absolute bottom-full mb-2 opacity-0 group-hover:opacity-100 transition-all duration-200 bg-slate-900 text-white text-[10px] px-3 py-2 rounded-xl shadow-xl pointer-events-none whitespace-nowrap z-20 flex flex-col items-center">
|
||||
<span className="font-bold text-xs text-purple-300">{d.label}</span>
|
||||
<span className="font-extrabold tracking-wide mt-0.5">
|
||||
{chartMetric === 'revenue' ? `₹${d.value.toLocaleString()}` : d.value.toLocaleString()}
|
||||
</span>
|
||||
{/* Tooltip caret */}
|
||||
<div className="absolute top-full w-2 h-2 bg-slate-900 rotate-45 -mt-1" />
|
||||
</div>
|
||||
|
||||
{/* Dot on line */}
|
||||
<div
|
||||
className="absolute w-3 h-3 rounded-full border-2 border-white bg-purple-600 shadow-md opacity-0 group-hover:opacity-100 transition-all duration-200 z-10 scale-50 group-hover:scale-100"
|
||||
style={{ bottom: `calc(${((d.value / maxChartVal) * 80)}% + 1.5rem)`, backgroundColor: theme.activeLine }}
|
||||
/>
|
||||
|
||||
{/* X-axis label */}
|
||||
<span className="text-[9px] font-bold text-slate-400 mt-2 rotate-0 truncate max-w-full px-1">{d.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../service
|
||||
import UsersPanel from './UsersPanel';
|
||||
import AwaitingApi from './AwaitingApi';
|
||||
import AdminConsole from './AdminConsole';
|
||||
import type { AuthUser } from '../services/auth';
|
||||
|
||||
type TabKey = 'profile' | 'outlets' | 'users';
|
||||
|
||||
@@ -122,9 +123,10 @@ function Row({
|
||||
|
||||
export interface SettingsViewProps {
|
||||
tenantId?: number;
|
||||
user?: AuthUser;
|
||||
}
|
||||
|
||||
export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsViewProps) {
|
||||
export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: SettingsViewProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('profile');
|
||||
|
||||
// Live tenant profile + outlets.
|
||||
@@ -242,10 +244,10 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
|
||||
<div className="relative z-10 flex items-center gap-3.5">
|
||||
{/* Initials avatar badge with glowing ring */}
|
||||
<div className="w-12 h-12 rounded-full bg-gradient-to-tr from-purple-500 to-indigo-650 border border-purple-400/30 flex items-center justify-center font-black text-sm shadow-[0_0_15px_rgba(168,85,247,0.35)] shrink-0">
|
||||
{tenant ? fstr(tenant.tenantname).substring(0, 2).toUpperCase() : 'ND'}
|
||||
{user?.name ? user.name.substring(0, 2).toUpperCase() : tenant ? fstr(tenant.tenantname).substring(0, 2).toUpperCase() : 'ND'}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h4 className="font-sans font-bold text-sm truncate text-white">{tenant ? fstr(tenant.tenantname) : 'Nearle Merchant'}</h4>
|
||||
<h4 className="font-sans font-bold text-sm truncate text-white">{user?.name || (tenant ? fstr(tenant.tenantname) : 'Nearle Merchant')}</h4>
|
||||
<p className="text-slate-400 text-[10px] font-mono mt-1 truncate uppercase tracking-wider">Store ID: #{tenantId}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -306,7 +308,7 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 leading-relaxed">
|
||||
<span className="text-base font-bold text-slate-900 mr-2">{fstr(tenant?.tenantname) || 'Nearle Store'}</span>
|
||||
<span className="text-base font-bold text-slate-900 mr-2">{user?.name || fstr(tenant?.tenantname) || 'Nearle Store'}</span>
|
||||
<span className="text-slate-500 text-xs font-medium">
|
||||
— Set up your store logo, customer service email, and contact number. Official registration details are synced with your primary credentials.
|
||||
</span>
|
||||
@@ -319,32 +321,32 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-md mt-3">
|
||||
<div className="p-4 bg-white rounded-xl border border-slate-200/60 shadow-sm">
|
||||
<span className="text-[10px] text-slate-400 uppercase font-black tracking-wider block">Company Name</span>
|
||||
<p className="font-bold text-slate-800 text-sm mt-1">{fstr(tenant?.companyname) || '—'}</p>
|
||||
<p className="font-bold text-slate-800 text-sm mt-1">{fstr(tenant?.companyname) || user?.name || 'Nearle Merchant'}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-white rounded-xl border border-slate-200/60 shadow-sm">
|
||||
<span className="text-[10px] text-slate-400 uppercase font-black tracking-wider block">Category</span>
|
||||
<p className="font-bold text-slate-800 text-sm mt-1">{fstr(tenant?.subcategoryname) || `Category ${fnum(tenant?.categoryid)}`}</p>
|
||||
<p className="font-bold text-slate-800 text-sm mt-1">{fstr(tenant?.subcategoryname) || (tenant?.categoryid ? `Category ${fnum(tenant.categoryid)}` : 'General Retail')}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-white rounded-xl border border-slate-200/60 shadow-sm">
|
||||
<span className="text-[10px] text-slate-400 uppercase font-black tracking-wider block">Registration Status</span>
|
||||
<p className="font-bold text-slate-800 text-sm mt-1 flex items-center gap-1.5">
|
||||
<span className={`w-2 h-2 rounded-full ${
|
||||
fstr(tenant?.status).toLowerCase() === 'active' ? 'bg-emerald-500' : 'bg-slate-400'
|
||||
(fstr(tenant?.status).toLowerCase() === 'active' || user) ? 'bg-emerald-500' : 'bg-slate-400'
|
||||
}`} />
|
||||
{fstr(tenant?.status) || '—'}
|
||||
{fstr(tenant?.status) || (user ? 'Active' : '—')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-white rounded-xl border border-slate-200/60 shadow-sm flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-[10px] text-slate-400 uppercase font-black tracking-wider block">Store Verification</span>
|
||||
<p className="font-bold text-slate-800 text-sm mt-1">{fnum(tenant?.approved) === 1 ? 'Verified' : 'Pending'}</p>
|
||||
<p className="font-bold text-slate-800 text-sm mt-1">{fnum(tenant?.approved) === 1 || user ? 'Verified' : 'Pending'}</p>
|
||||
</div>
|
||||
<span className={`px-2.5 py-1 rounded-lg text-[9px] font-black uppercase tracking-wider ${
|
||||
fnum(tenant?.approved) === 1
|
||||
fnum(tenant?.approved) === 1 || user
|
||||
? 'text-emerald-700 bg-emerald-50 border border-emerald-100/50'
|
||||
: 'text-zinc-555 bg-zinc-100'
|
||||
}`}>
|
||||
{fnum(tenant?.approved) === 1 ? 'Verified' : 'Pending'}
|
||||
{fnum(tenant?.approved) === 1 || user ? 'Verified' : 'Pending'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -354,7 +356,7 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
|
||||
<div>
|
||||
<span className="text-[10px] text-slate-400 uppercase font-black tracking-wider block">Registered Address</span>
|
||||
<p className="text-slate-700 font-semibold text-xs mt-1 leading-relaxed">
|
||||
{fstr(tenant?.address) || '—'}
|
||||
{fstr(tenant?.address) || [user?.locationname, user?.applocation].filter(Boolean).join(', ') || 'Headquarters'}
|
||||
{tenant?.city ? ` · ${fstr(tenant.city)}, ${fstr(tenant.state)} ${fstr(tenant.postcode)}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
@@ -371,7 +373,7 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={fstr(tenant?.primaryemail) || ''}
|
||||
value={user?.email || 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="—"
|
||||
@@ -383,7 +385,7 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={fstr(tenant?.primarycontact) || ''}
|
||||
value={user?.contactno || 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="—"
|
||||
|
||||
@@ -41,10 +41,13 @@ import {
|
||||
useFiestaTenantCustomers,
|
||||
useFiestaCustomerOrders,
|
||||
useFiestaRiders,
|
||||
useFiestaRevenueSummary,
|
||||
useFiestaAllOrders,
|
||||
FIESTA_TENANT_ID
|
||||
} from '../services/fiestaQueries';
|
||||
import { str as fstr, num as fnum } from '../services/fiestaApi';
|
||||
import { str as fstr, num as fnum, ymd } from '../services/fiestaApi';
|
||||
import { mapOrderStatus, shortTime } from '../services/fiestaMappers';
|
||||
import { ResponsiveContainer, AreaChart, Area, XAxis, YAxis, Tooltip as RechartsTooltip, CartesianGrid } from 'recharts';
|
||||
import ragulStoreCover from '../assets/images/store_front_view_1780299351800.png';
|
||||
import OrdersDeliveriesView from './OrdersDeliveriesView';
|
||||
import StoreQRView from './StoreQRView';
|
||||
@@ -163,6 +166,40 @@ export default function StoreDetailView({ store, onBack, canManage = true, only,
|
||||
customerid: selectedCustomer?.id ?? null,
|
||||
pagesize: 20
|
||||
});
|
||||
|
||||
const todayStr = ymd(new Date());
|
||||
const revenueQ = useFiestaRevenueSummary({ tenantid: tenantId, locationid, fromdate: todayStr, todate: todayStr });
|
||||
const totalRevenue = revenueQ.data?.grossrevenue ?? 0;
|
||||
|
||||
const monthStart = ymd(new Date(new Date().getFullYear(), new Date().getMonth(), 1));
|
||||
const monthRevenueQ = useFiestaRevenueSummary({ tenantid: tenantId, locationid, fromdate: monthStart, todate: todayStr });
|
||||
const monthlyRevenue = monthRevenueQ.data?.grossrevenue ?? 0;
|
||||
|
||||
// All orders for today to build the intraday chart
|
||||
const todayOrdersQ = useFiestaAllOrders({ tenantid: tenantId, locationid, fromdate: todayStr, todate: todayStr });
|
||||
|
||||
const intradayChartData = React.useMemo(() => {
|
||||
const map = new Map<string, { hour: string; orders: number; revenue: number }>();
|
||||
// Pre-fill 8 AM to 10 PM
|
||||
for (let i = 8; i <= 22; i++) {
|
||||
const hStr = i.toString().padStart(2, '0') + ':00';
|
||||
map.set(hStr, { hour: hStr, orders: 0, revenue: 0 });
|
||||
}
|
||||
|
||||
for (const r of (todayOrdersQ.data ?? [])) {
|
||||
const dateVal = fstr(r.orderdate) || fstr(r.deliverydate);
|
||||
if (!dateVal) continue;
|
||||
const timePart = dateVal.split('T')[1];
|
||||
if (!timePart) continue;
|
||||
|
||||
const hourStr = timePart.substring(0, 2) + ':00';
|
||||
const ex = map.get(hourStr) || { hour: hourStr, orders: 0, revenue: 0 };
|
||||
ex.orders += 1;
|
||||
ex.revenue += (fnum(r.ordervalue) || fnum(r.orderamount) || fnum(r.deliveryamt) || 0);
|
||||
map.set(hourStr, ex);
|
||||
}
|
||||
return Array.from(map.values()).sort((a, b) => a.hour.localeCompare(b.hour));
|
||||
}, [todayOrdersQ.data]);
|
||||
const customerOrderHistory = (customerOrdersQ.data ?? []).map((row: any) => {
|
||||
const amount = fnum(row.orderamount) || fnum(row.amount);
|
||||
return {
|
||||
@@ -533,11 +570,15 @@ export default function StoreDetailView({ store, onBack, canManage = true, only,
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-gutter">
|
||||
<div className="bg-white border border-[#eceef2] rounded-2xl p-md shadow-sm hover:shadow-md transition-all duration-200 hover:-translate-y-0.5 relative group overflow-hidden">
|
||||
<div className="w-8 h-8 rounded-xl bg-purple-50 text-[#581c87] flex items-center justify-center mb-sm group-hover:scale-110 transition-transform">
|
||||
<CheckCircle2 size={16} />
|
||||
<Activity size={16} />
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest block">OTIF Fulfillment</span>
|
||||
<span className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest block">Monthly Revenue</span>
|
||||
<div className="mt-xs">
|
||||
<AwaitingApi label="OTIF fulfillment" api="[R1]" compact />
|
||||
{monthRevenueQ.isLoading ? (
|
||||
<span className="text-xs text-zinc-400 font-medium">Loading...</span>
|
||||
) : (
|
||||
<p className="text-2xl font-black text-[#0f172a]">₹{monthlyRevenue.toLocaleString('en-IN')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -547,7 +588,11 @@ export default function StoreDetailView({ store, onBack, canManage = true, only,
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest block">Est. Revenue</span>
|
||||
<div className="mt-xs">
|
||||
<AwaitingApi label="Outlet revenue" api="[R1]" compact />
|
||||
{revenueQ.isLoading ? (
|
||||
<span className="text-xs text-zinc-400 font-medium">Loading...</span>
|
||||
) : (
|
||||
<p className="text-2xl font-black text-[#0f172a]">₹{totalRevenue.toLocaleString('en-IN')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -591,9 +636,48 @@ export default function StoreDetailView({ store, onBack, canManage = true, only,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Intraday dispatch breakdown has no backend yet ([R10]). */}
|
||||
<div className="my-xl">
|
||||
<AwaitingApi label="Intraday dispatch breakdown" api="[R10]" />
|
||||
{/* Intraday dispatch breakdown */}
|
||||
<div className="my-md h-64">
|
||||
{todayOrdersQ.isLoading ? (
|
||||
<div className="w-full h-full flex items-center justify-center text-xs text-zinc-400">Loading intraday data...</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={intradayChartData} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="intraRev" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#581c87" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#581c87" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#eceef2" />
|
||||
<XAxis dataKey="hour" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} dy={10} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} dx={-10} tickFormatter={(v) => `₹${v}`} />
|
||||
<RechartsTooltip
|
||||
content={({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-white p-3 rounded-xl border border-zinc-200 shadow-lg">
|
||||
<p className="text-[10px] font-bold text-zinc-400 uppercase tracking-wider mb-2">{label}</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="flex items-center gap-2 text-xs font-medium text-zinc-600">
|
||||
<span className="w-2 h-2 rounded-full bg-[#581c87]"></span>
|
||||
Revenue: <strong className="text-zinc-900">₹{payload[0].value.toLocaleString('en-IN')}</strong>
|
||||
</span>
|
||||
<span className="flex items-center gap-2 text-xs font-medium text-zinc-600">
|
||||
<span className="w-2 h-2 rounded-full bg-sky-500"></span>
|
||||
Orders: <strong className="text-zinc-900">{payload[0].payload.orders}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Area type="monotone" dataKey="revenue" stroke="#581c87" strokeWidth={2} fillOpacity={1} fill="url(#intraRev)" activeDot={{ r: 4, strokeWidth: 2, stroke: '#fff', fill: '#581c87' }} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -643,65 +727,7 @@ export default function StoreDetailView({ store, onBack, canManage = true, only,
|
||||
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-gutter">
|
||||
|
||||
{/* Past 7 days Table */}
|
||||
<div className="lg:col-span-2 bg-white border border-[#eceef2] rounded-2xl p-md shadow-sm">
|
||||
<h3 className="font-sans font-bold text-sm text-[#0f172a] mb-md flex items-center gap-xs">
|
||||
<Calendar size={15} className="text-[#581c87]" /> Past 7 Days Ledger Log
|
||||
</h3>
|
||||
|
||||
{/* Daily ledger series has no backend yet ([R2]). */}
|
||||
<AwaitingApi label="Daily ledger" api="[R2]" />
|
||||
</div>
|
||||
|
||||
{/* Live Rider fleet list */}
|
||||
<div className="bg-white border border-[#eceef2] rounded-2xl p-md shadow-sm flex flex-col justify-between">
|
||||
<div>
|
||||
<h3 className="font-sans font-bold text-sm text-[#0f172a] flex items-center gap-xs">
|
||||
<ShoppingCart size={15} className="text-[#581c87]" /> Active Rider Fleet
|
||||
</h3>
|
||||
<p className="text-zinc-405 text-[10px] font-sans mt-0.5">Live status of assigned riders for this tenant.</p>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-[#f1f5f9] text-xs font-sans mt-md">
|
||||
{activeRiders.length === 0 ? (
|
||||
<div className="py-8 text-center text-zinc-400 font-medium">
|
||||
No active riders assigned yet.
|
||||
</div>
|
||||
) : (
|
||||
activeRiders.map((rider, index) => (
|
||||
<div key={index} className="py-2 flex justify-between items-center group">
|
||||
<div className="flex items-center gap-sm min-w-0">
|
||||
<div className="w-7 h-7 rounded-full bg-purple-50 text-[#581c87] border border-purple-100 font-black text-[10px] flex items-center justify-center shrink-0">
|
||||
{rider.initial}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-bold text-[#0f172a] truncate">{rider.name.split(' ')[0]} {rider.name.split(' ').slice(-1)[0]}</p>
|
||||
<p className="text-[9px] text-zinc-400 flex items-center gap-1 font-semibold uppercase">
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${rider.status === 'Idle' ? 'bg-amber-400' : 'bg-emerald-500'}`} />
|
||||
{rider.status} · {rider.orders} orders
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canManage && (
|
||||
<div className="flex items-center gap-sm text-right shrink-0">
|
||||
<button
|
||||
onClick={() => showToast(`SMS alert broadcasted to rider ${rider.name.split(' ')[0]}.`, 'success')}
|
||||
className="px-2 py-1 border border-zinc-200 rounded-lg hover:border-purple-300 text-[9px] font-bold hover:bg-purple-50/50 hover:text-[#581c87] cursor-pointer transition"
|
||||
>
|
||||
Ping
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -191,7 +191,7 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
||||
// Logistics console — scoped to this user's store. These views own their
|
||||
// loading/error states, so they don't need the store-console load gating below.
|
||||
if (activeSection === 'dispatch') return <DispatchHubView locationid={resolvedLocationId || undefined} tenantId={tenantId} />;
|
||||
if (activeSection === 'reports') return <DeliveryReportsView tenantId={tenantId} />;
|
||||
if (activeSection === 'reports') return <DeliveryReportsView tenantId={tenantId} locationid={resolvedLocationId || undefined} />;
|
||||
// Inventory & Catalog is its own page: the manager-curated catalog the user
|
||||
// stocks from (the catalog query is tenant-level, so it doesn't need the store
|
||||
// gating below — only "My Store Inventory" uses the resolved location id).
|
||||
|
||||
@@ -51,6 +51,7 @@ const ROLE_THEMES: Record<number, { bg: string; text: string; border: string; la
|
||||
2: { bg: 'bg-amber-50/75', text: 'text-amber-700', border: 'border-amber-100', label: 'Admin' },
|
||||
3: { bg: 'bg-blue-50/75', text: 'text-blue-700', border: 'border-blue-100', label: 'Admin' },
|
||||
4: { bg: 'bg-emerald-50/75', text: 'text-emerald-700', border: 'border-emerald-100', label: 'Staff' },
|
||||
5: { bg: 'bg-purple-50/75', text: 'text-purple-700', border: 'border-purple-100', label: 'Rider' },
|
||||
6: { bg: 'bg-indigo-50/75', text: 'text-indigo-700', border: 'border-indigo-100', label: 'Cashier' },
|
||||
};
|
||||
|
||||
@@ -187,7 +188,9 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
|
||||
});
|
||||
|
||||
const roleOptions = React.useMemo(() => {
|
||||
return Array.from(new Set(users.map((u) => u.role)));
|
||||
const roles = new Set(users.map((u) => u.role));
|
||||
roles.add('Rider');
|
||||
return Array.from(roles);
|
||||
}, [users]);
|
||||
|
||||
const handleCreateUser = async (e: React.FormEvent) => {
|
||||
@@ -277,32 +280,32 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
|
||||
</div>
|
||||
|
||||
{/* Search & Filter Utility Bar */}
|
||||
<div className="bg-slate-50/50 border border-slate-200/60 p-4 rounded-2xl flex flex-col md:flex-row gap-4 items-stretch md:items-center justify-between select-none">
|
||||
<div className="relative w-full md:max-w-md shrink-0 group">
|
||||
<div className="relative flex items-center bg-white border border-slate-200 rounded-xl transition-all duration-300 shadow-sm focus-within:ring-4 focus-within:ring-purple-150 focus-within:border-purple-600 hover:border-slate-300">
|
||||
<span className="pl-4 flex items-center pointer-events-none text-slate-400 group-focus-within:text-purple-600 transition-colors">
|
||||
<Search className="w-4.5 h-4.5" />
|
||||
<div className="bg-white border border-slate-200/80 p-3 rounded-2xl flex flex-col md:flex-row gap-4 items-stretch md:items-center justify-between shadow-sm">
|
||||
<div className="relative w-full md:max-w-md shrink-0">
|
||||
<div className="relative flex w-full items-center bg-slate-50 border border-slate-200/80 rounded-xl transition-all duration-300 focus-within:bg-white focus-within:ring-4 focus-within:ring-purple-500/10 focus-within:border-purple-500 group-search shadow-inner">
|
||||
<span className="pl-4 flex items-center pointer-events-none text-slate-400 transition-colors">
|
||||
<Search size={18} strokeWidth={2.5} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search team members by name, email, phone..."
|
||||
placeholder="Search team members..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-3 pr-10 py-3 bg-transparent border-none text-xs font-semibold text-slate-800 placeholder-slate-400 focus:outline-none outline-none"
|
||||
className="w-full pl-3 pr-12 py-3 bg-transparent border-none text-sm font-semibold text-slate-800 placeholder-slate-400 focus:outline-none outline-none"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 active:scale-95 transition-all p-1 hover:bg-slate-100 rounded-lg"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-700 hover:bg-slate-200/50 active:scale-95 transition-all p-1.5 rounded-lg flex items-center justify-center"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
<X size={16} strokeWidth={2.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role filter capsules */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-1 md:pb-0 scrollbar-none">
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-1 md:pb-0 scrollbar-none select-none">
|
||||
<button
|
||||
onClick={() => setUserRoleFilter('ALL')}
|
||||
className={`px-3.5 py-2 rounded-xl text-xs uppercase tracking-wider font-extrabold transition-all duration-200 cursor-pointer border ${
|
||||
@@ -389,6 +392,7 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
|
||||
{u.roleid === 2 && <Shield size={12} />}
|
||||
{u.roleid === 3 && <SlidersHorizontal size={12} />}
|
||||
{u.roleid === 4 && <User size={12} />}
|
||||
{u.roleid === 5 && <Bike size={12} />}
|
||||
{u.roleid === 6 && <Coins size={12} />}
|
||||
{roleInfo.label}
|
||||
</span>
|
||||
|
||||
@@ -131,6 +131,8 @@ export interface KpiItem {
|
||||
color: string;
|
||||
icon: React.ReactNode;
|
||||
badge?: string;
|
||||
onClick?: () => void;
|
||||
active?: boolean;
|
||||
}
|
||||
export function KpiStrip({ items, loading }: { items: KpiItem[]; loading?: boolean }) {
|
||||
return (
|
||||
@@ -138,10 +140,11 @@ export function KpiStrip({ items, loading }: { items: KpiItem[]; loading?: boole
|
||||
{items.map((it) => (
|
||||
<div
|
||||
key={it.label}
|
||||
className="relative overflow-hidden rounded-2xl border bg-white p-3.5 sm:p-5 transition-all duration-200 hover:-translate-y-0.5"
|
||||
style={{ borderColor: BORDER }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.boxShadow = SHADOW_MD)}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.boxShadow = 'none')}
|
||||
onClick={it.onClick}
|
||||
className={`relative overflow-hidden rounded-2xl border bg-white p-3.5 sm:p-5 transition-all duration-200 ${it.onClick ? 'cursor-pointer hover:-translate-y-0.5' : ''}`}
|
||||
style={{ borderColor: it.active ? it.color : BORDER, boxShadow: it.active ? `0 0 0 1px ${it.color}, ${SHADOW_MD}` : 'none' }}
|
||||
onMouseEnter={(e) => { if (!it.active && it.onClick) e.currentTarget.style.boxShadow = SHADOW_MD; }}
|
||||
onMouseLeave={(e) => { if (!it.active) e.currentTarget.style.boxShadow = 'none'; }}
|
||||
>
|
||||
<div className="absolute top-0 left-0 right-0" style={{ height: 3, background: `linear-gradient(90deg, ${it.color} 0%, ${soft(it.color)} 100%)` }} />
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
@@ -303,3 +306,25 @@ export const TH_STYLE: React.CSSProperties = {
|
||||
whiteSpace: 'nowrap',
|
||||
borderBottom: `1px solid ${BORDER}`,
|
||||
};
|
||||
|
||||
export function TableShell({ head, children, minWidth, footer }: { head: string[]; children: React.ReactNode; minWidth?: number; footer?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border overflow-hidden shadow-sm flex flex-col" style={{ borderColor: BORDER }}>
|
||||
<div className="overflow-x-auto flex-1">
|
||||
<table className="w-full text-sm text-left whitespace-nowrap" style={{ minWidth }}>
|
||||
<thead className="sticky top-0 z-10" style={TH_STYLE}>
|
||||
<tr>
|
||||
{head.map((h, i) => (
|
||||
<th key={i} className={`px-4 py-3 ${i > 1 ? 'text-right' : ''}`}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y" style={{ borderColor: DIVIDER }}>
|
||||
{children}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,6 +125,46 @@ export interface FiestaOrderSummary {
|
||||
tenantname?: string;
|
||||
}
|
||||
|
||||
export interface FiestaLocationRevenue {
|
||||
locationid: number;
|
||||
locationname: string;
|
||||
revenue: number;
|
||||
}
|
||||
|
||||
export interface FiestaRevenueSummary {
|
||||
tenantid: number;
|
||||
tenantname: string;
|
||||
overallrevenue: number;
|
||||
locationrevenue: FiestaLocationRevenue[];
|
||||
}
|
||||
|
||||
/** /orders/getrevenuesummary?tenantid=&locationid=&fromdate=&todate= — tenant revenue and location breakdown. */
|
||||
export async function getRevenueSummary(opts: {
|
||||
tenantid: number;
|
||||
fromdate: string;
|
||||
todate: string;
|
||||
locationid?: number;
|
||||
}) {
|
||||
const res = await fiestaGet('orders/getrevenuesummary', opts);
|
||||
return firstRow<{
|
||||
tenantid: number;
|
||||
locationid: number | null;
|
||||
grossrevenue: number;
|
||||
netrevenue: number;
|
||||
profit: number;
|
||||
marginpct: number;
|
||||
ordercount: number;
|
||||
deliveredcount: number;
|
||||
cancelledcount: number;
|
||||
otifpct: number;
|
||||
avgordervalue: number;
|
||||
prev_grossrevenue: number;
|
||||
prev_ordercount: number;
|
||||
}>(res);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** /orders/getordersummary?tenantid=&locationid=&fromdate=&todate= — flat order counts. */
|
||||
export async function getOrderSummary(
|
||||
tenantid: number,
|
||||
@@ -203,6 +243,25 @@ export async function getOrders(opts: {
|
||||
);
|
||||
}
|
||||
|
||||
/** /orders/gettimeseries?tenantid=&locationid=&granularity=&fromdate=&todate= — time-series chart data */
|
||||
export async function getTimeSeries(opts: {
|
||||
tenantid: number;
|
||||
granularity: 'day' | 'month' | 'year';
|
||||
fromdate: string;
|
||||
todate: string;
|
||||
locationid?: number;
|
||||
}) {
|
||||
const res = await fiestaGet('orders/gettimeseries', opts);
|
||||
return toRows<{
|
||||
label: string;
|
||||
orders: number;
|
||||
revenue: number;
|
||||
delivered: number;
|
||||
cancelled: number;
|
||||
activeskus: number;
|
||||
}>(res);
|
||||
}
|
||||
|
||||
/** /orders/getorderdetails?orderheaderid= — line items for a single order. */
|
||||
export async function getOrderDetails(orderheaderid: number | string): Promise<Row[]> {
|
||||
let cleanId = String(orderheaderid).trim();
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
FIESTA_APPLOCATION_ID,
|
||||
FIESTA_PRIMARY_LOCATION_ID,
|
||||
getOrderSummary,
|
||||
getRevenueSummary,
|
||||
getTimeSeries,
|
||||
getLocationSummary,
|
||||
getOrderInsight,
|
||||
getOrders,
|
||||
@@ -62,6 +64,8 @@ import {
|
||||
export const fiestaKeys = {
|
||||
orderSummary: (tenantid: number, fromdate: string, todate: string, locationid?: number) =>
|
||||
['fiesta', 'orderSummary', tenantid, fromdate, todate, locationid ?? 0] as const,
|
||||
revenueSummary: (params: Record<string, unknown>) => ['fiesta', 'revenueSummary', params] as const,
|
||||
timeSeries: (params: Record<string, unknown>) => ['fiesta', 'timeSeries', params] as const,
|
||||
locationSummary: (tenantid: number) => ['fiesta', 'locationSummary', tenantid] as const,
|
||||
orderInsight: (tenantid: number) => ['fiesta', 'orderInsight', tenantid] as const,
|
||||
orders: (params: Record<string, unknown>) => ['fiesta', 'orders', params] as const,
|
||||
@@ -102,6 +106,33 @@ export function useFiestaOrderSummary(tenantid: number = FIESTA_TENANT_ID, fromd
|
||||
});
|
||||
}
|
||||
|
||||
export function useFiestaRevenueSummary(opts: {
|
||||
tenantid: number;
|
||||
fromdate: string;
|
||||
todate: string;
|
||||
locationid?: number;
|
||||
}) {
|
||||
return useQuery({
|
||||
queryKey: fiestaKeys.revenueSummary(opts as Record<string, unknown>),
|
||||
queryFn: () => getRevenueSummary(opts),
|
||||
enabled: Boolean(opts.tenantid && opts.fromdate && opts.todate),
|
||||
});
|
||||
}
|
||||
|
||||
export function useFiestaTimeSeries(opts: {
|
||||
tenantid: number;
|
||||
granularity: 'day' | 'month' | 'year';
|
||||
fromdate: string;
|
||||
todate: string;
|
||||
locationid?: number;
|
||||
}) {
|
||||
return useQuery({
|
||||
queryKey: fiestaKeys.timeSeries(opts as Record<string, unknown>),
|
||||
queryFn: () => getTimeSeries(opts),
|
||||
enabled: Boolean(opts.tenantid && opts.fromdate && opts.todate),
|
||||
});
|
||||
}
|
||||
|
||||
export function useFiestaLocationSummary(tenantid: number = FIESTA_TENANT_ID) {
|
||||
return useQuery({
|
||||
queryKey: fiestaKeys.locationSummary(tenantid),
|
||||
@@ -153,10 +184,16 @@ export function useFiestaAllOrders(opts: {
|
||||
return useQuery({
|
||||
queryKey: ['fiesta', 'allOrders', opts],
|
||||
queryFn: async () => {
|
||||
const statuses = ['created', 'pending', 'processing', 'delivered', 'cancelled'];
|
||||
const results = await Promise.all(
|
||||
statuses.map(status =>
|
||||
getOrders({
|
||||
// Include all known statuses from ORDER_STATUS_MAP to ensure we don't miss orders
|
||||
const statuses = [
|
||||
'created', 'pending', 'processing', 'delivered', 'cancelled',
|
||||
'accepted', 'assigned', 'ready', 'picked', 'active', 'arrived'
|
||||
];
|
||||
// Fetch sequentially to avoid rate-limiting or proxy dropping parallel requests
|
||||
const results: Row[][] = [];
|
||||
for (const status of statuses) {
|
||||
try {
|
||||
const res = await getOrders({
|
||||
tenantid: opts.tenantid,
|
||||
status,
|
||||
fromdate: opts.fromdate,
|
||||
@@ -164,10 +201,13 @@ export function useFiestaAllOrders(opts: {
|
||||
locationid: opts.locationid,
|
||||
applocationid: opts.applocationid,
|
||||
keyword: opts.keyword,
|
||||
pagesize: 100,
|
||||
}).catch(() => [] as Row[])
|
||||
)
|
||||
);
|
||||
pagesize: 500,
|
||||
});
|
||||
results.push(res);
|
||||
} catch (e) {
|
||||
results.push([]);
|
||||
}
|
||||
}
|
||||
// Merge and deduplicate by orderid/orderheaderid
|
||||
const merged: Row[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
Reference in New Issue
Block a user