diff --git a/src/menu-items/nearle.js b/src/menu-items/nearle.js
index 3b7d19e..d1fdc9a 100644
--- a/src/menu-items/nearle.js
+++ b/src/menu-items/nearle.js
@@ -149,6 +149,13 @@ const nearle = {
url: '/nearle/reports/riderslogs',
icon: DirectionsBikeOutlinedIcon
// target: true
+ },
+ {
+ id: 'profitability',
+ title: ,
+ type: 'item',
+ url: '/nearle/reports/profitability',
+ icon: icons.BarChartOutlined
}
]
},
diff --git a/src/pages/nearle/dispatch/ProfitabilitySection.js b/src/pages/nearle/dispatch/ProfitabilitySection.js
index 5c5ca71..746c0fd 100644
--- a/src/pages/nearle/dispatch/ProfitabilitySection.js
+++ b/src/pages/nearle/dispatch/ProfitabilitySection.js
@@ -49,7 +49,7 @@ function getStatusConfig(raw) {
}
function orderRevenue(order) {
- const km = parseFloat(order.kms ?? order.actualkms ?? 0);
+ const km = parseFloat(order.kms || order.actualkms || 0);
return km <= BASE_KM_LIMIT ? BASE_REVENUE : BASE_REVENUE + (km - BASE_KM_LIMIT) * EXTRA_RATE_KM;
}
@@ -60,7 +60,7 @@ function calcRiderMetrics(rider) {
for (const o of orders) {
revenue += orderRevenue(o);
- kms += parseFloat(o.kms ?? o.actualkms ?? 0);
+ kms += parseFloat(o.kms || o.actualkms || 0);
}
const varCost = kms * VARIABLE_RATE_KM;
@@ -472,8 +472,13 @@ export default function ProfitabilitySection({ riders = [], totalDailyProfit = 0
{slotIsProfit ? '+' : ''}
{rupees(slotNet)}
- 0 ? (slotNet / slotRevenue >= 0 ? 'margin-positive' : 'margin-negative') : ''}`}>
- {slotRevenue > 0 ? `${slotNet / slotRevenue >= 0 ? '+' : ''}${((slotNet / slotRevenue) * 100).toFixed(0)}% margin` : '0% margin'}
+ 0 ? (slotNet / slotRevenue >= 0 ? 'margin-positive' : 'margin-negative') : ''
+ }`}
+ >
+ {slotRevenue > 0
+ ? `${slotNet / slotRevenue >= 0 ? '+' : ''}${((slotNet / slotRevenue) * 100).toFixed(0)}% margin`
+ : '0% margin'}
diff --git a/src/pages/nearle/reports/profitability.js b/src/pages/nearle/reports/profitability.js
new file mode 100644
index 0000000..b80ec6a
--- /dev/null
+++ b/src/pages/nearle/reports/profitability.js
@@ -0,0 +1,773 @@
+import React, { useState, useEffect, useMemo } from 'react';
+import PropTypes from 'prop-types';
+import { useInfiniteQuery } from '@tanstack/react-query';
+import {
+ Avatar,
+ Box,
+ Chip,
+ Grid,
+ Paper,
+ Stack,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ Tooltip,
+ Typography,
+ useMediaQuery,
+ useTheme
+} from '@mui/material';
+import {
+ MdMyLocation,
+ MdCalendarMonth,
+ MdPerson,
+ MdOutlineLocalShipping,
+ MdOutlineCurrencyRupee,
+ MdStraighten,
+ MdPayments,
+ MdRoute,
+ MdTrendingUp,
+ MdTrendingDown
+} from 'react-icons/md';
+
+import dayjs from 'dayjs';
+var utc = require('dayjs/plugin/utc');
+dayjs.extend(utc);
+
+import { fetchDeliveries } from 'pages/api/api';
+import Loader from 'components/Loader';
+import DateFilterDialog from 'components/DateFilterDialog';
+import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete';
+import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
+import PageHeader from 'components/nearle_components/PageHeader';
+import StatCard from 'components/nearle_components/StatCard';
+import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
+
+const DT = {
+ radiusPill: 999,
+ radiusCard: 14,
+ shadowSoft: '0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.05)',
+ shadowMd: '0 1px 3px rgba(15, 23, 42, 0.06)',
+ shadowPop: '0 12px 32px rgba(15, 23, 42, 0.12)',
+ textPrimary: '#0f172a',
+ textSecondary: '#64748b',
+ textMuted: '#94a3b8',
+ borderSubtle: '#e2e8f0',
+ divider: '#f1f5f9',
+ surface: '#ffffff',
+ surfaceAlt: '#f8fafc'
+};
+
+const aColor = (c, suffix) => `${c}${suffix}`;
+const soft = (c) => aColor(c, '18');
+const tint = (c) => aColor(c, '08');
+const edge = (c) => aColor(c, '55');
+const ring = (c) => aColor(c, '26');
+
+const BRAND = '#662582';
+
+const SoftPaper = (props) => (
+
+);
+
+SoftPaper.propTypes = {
+ children: PropTypes.node
+};
+
+const AccentAvatar = ({ color, selected, size = 24, children }) => (
+
+ {children}
+
+);
+
+AccentAvatar.propTypes = {
+ color: PropTypes.string.isRequired,
+ selected: PropTypes.bool,
+ size: PropTypes.number,
+ children: PropTypes.node
+};
+
+const MetricPill = ({ color, icon, label, tooltip, minWidth = 80 }) => (
+
+
+ {icon}
+ {label}
+
+
+);
+
+MetricPill.propTypes = {
+ color: PropTypes.string.isRequired,
+ icon: PropTypes.node,
+ label: PropTypes.string.isRequired,
+ tooltip: PropTypes.string,
+ minWidth: PropTypes.number
+};
+
+const BATCHES = [
+ { id: 'morning', name: 'Morning Batch', startHour: 0, endHour: 8 },
+ { id: 'afternoon', name: 'Afternoon Batch', startHour: 9, endHour: 12.5 },
+ { id: 'evening', name: 'Evening Batch', startHour: 16, endHour: 19 }
+];
+
+const getBatchForHour = (h, batches = BATCHES) => {
+ for (const b of batches) {
+ if (h >= b.startHour && h < b.endHour) return b.id;
+ }
+ return null;
+};
+
+const getRowBatch = (r, batches = BATCHES) => {
+ const t = r?.assigntime;
+ if (!t) return null;
+ const str = String(t).trim();
+ if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return null;
+ const d = dayjs(t);
+ if (!d.isValid()) return null;
+ return getBatchForHour(d.hour() + d.minute() / 60, batches);
+};
+
+function formatNumberToRupees(value) {
+ return new Intl.NumberFormat('en-IN', {
+ style: 'currency',
+ currency: 'INR',
+ minimumFractionDigits: 2
+ }).format(Number(value) || 0);
+}
+
+export default function ProfitabilityReport() {
+ const theme = useTheme();
+ const isMobile = useMediaQuery(theme.breakpoints.down('md'));
+
+ const [startdate, setStartdate] = useState(dayjs().format('YYYY-MM-DD'));
+ const [enddate, setEnddate] = useState(dayjs().format('YYYY-MM-DD'));
+ const [locaName, setLocoName] = useState('All');
+ const [open, setOpen] = useState(false);
+ const [datestatus, setDatestatus] = useState('Today');
+ const [appId, setAppId] = useState(0);
+
+ const [searchword, setSearchword] = useState('');
+ const [debouncedSearch, setDebouncedSearch] = useState('');
+
+ const liveUserid = typeof window !== 'undefined' ? localStorage.getItem('userid') || 0 : 0;
+
+ // Load slots configuration from localStorage to match Dispatch page edits
+ const customBatches = useMemo(() => {
+ if (typeof window === 'undefined') return BATCHES;
+ try {
+ const raw = window.localStorage.getItem('dispatch.slots.v9');
+ if (!raw) return BATCHES;
+ const parsed = JSON.parse(raw);
+ if (!Array.isArray(parsed) || parsed.length !== BATCHES.length) return BATCHES;
+ return parsed.map((s, i) => {
+ const id = s.id || `slot-${i + 1}`;
+ const startHour = Number(s.startHour) || 0;
+ const endHour = Number(s.endHour) || 24;
+ return {
+ id,
+ name: s.name || BATCHES.find((b) => b.id === id)?.name || `Slot ${i + 1}`,
+ startHour,
+ endHour
+ };
+ });
+ } catch (e) {
+ return BATCHES;
+ }
+ }, []);
+
+ // Fetch all deliveries for the selected date range and zone
+ const {
+ data: deliveriesData,
+ isLoading: isLoadingDeliveries,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage
+ } = useInfiniteQuery({
+ queryKey: ['fetchdeliveries', appId, liveUserid, 'all', startdate, enddate, 50, '', 0, 0, 0],
+ queryFn: fetchDeliveries,
+ getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined,
+ refetchOnWindowFocus: false
+ });
+
+ // Auto-page through all results
+ useEffect(() => {
+ if (hasNextPage && !isFetchingNextPage) {
+ fetchNextPage();
+ }
+ }, [hasNextPage, isFetchingNextPage, fetchNextPage]);
+
+ // Flatten and deduplicate deliveries by orderid
+ const liveRows = useMemo(() => {
+ const all = (deliveriesData?.pages || []).flatMap((p) => p.rows || []);
+ const seen = new Set();
+ const out = [];
+ for (const r of all) {
+ const key = r.orderid != null ? String(r.orderid) : null;
+ if (key && seen.has(key)) continue;
+ if (key) seen.add(key);
+ out.push(r);
+ }
+ return out;
+ }, [deliveriesData]);
+
+ // Group deliveries by rider
+ const ridersList = useMemo(() => {
+ const riderMap = {};
+ liveRows.forEach((r) => {
+ const key = String(r.userid || r.rider_id || '');
+ if (!key || key === 'unassigned' || key === '0') return;
+ if (!riderMap[key]) {
+ riderMap[key] = {
+ id: key,
+ riderName: r.ridername || r.rider_name || r.username || `Rider ${key}`,
+ orders: []
+ };
+ }
+ if (!riderMap[key].orders.some((existing) => existing.orderid === r.orderid)) {
+ riderMap[key].orders.push(r);
+ }
+ });
+
+ return Object.values(riderMap)
+ .map((r) => ({
+ ...r,
+ orders: [...r.orders].sort((a, b) => {
+ const tA = a.trip_number || 1;
+ const tB = b.trip_number || 1;
+ if (tA !== tB) return tA - tB;
+ return (a.step || 0) - (b.step || 0);
+ })
+ }))
+ .sort((a, b) => b.orders.length - a.orders.length);
+ }, [liveRows]);
+
+ // Calculate profitability metrics for all riders
+ const stats = useMemo(() => {
+ let activeRiders = 0;
+ let totalOrders = 0;
+ let totalRevenue = 0;
+ let totalCost = 0;
+ let profitableRiders = 0;
+ let lossRiders = 0;
+
+ const list = ridersList
+ .map((r) => {
+ let rRevenue = 0;
+ let rKms = 0;
+ const activeSlots = new Set();
+ let ordersInSlots = 0;
+
+ r.orders.forEach((o) => {
+ const slot = getRowBatch(o, customBatches);
+ if (!slot) return;
+
+ const oKms = parseFloat(o.kms || o.actualkms || 0);
+ rKms += oKms;
+ rRevenue += oKms <= 8 ? 30 : 30 + (oKms - 8) * 6;
+ const dateStr = o.assigntime ? dayjs(o.assigntime).format('YYYY-MM-DD') : 'unknown';
+ activeSlots.add(`${dateStr}_${slot}`);
+ ordersInSlots++;
+ });
+
+ if (ordersInSlots === 0) {
+ return null;
+ }
+
+ const rVarCost = rKms * 2.5;
+ const slotCount = activeSlots.size;
+ const rFixedCost = slotCount * 166.67;
+ const rTotalCost = rVarCost + rFixedCost;
+ const rNet = rRevenue - rTotalCost;
+ const rMargin = rRevenue > 0 ? (rNet / rRevenue) * 100 : 0;
+
+ if (rNet >= 0) {
+ profitableRiders++;
+ } else {
+ lossRiders++;
+ }
+
+ totalOrders += ordersInSlots;
+ totalRevenue += rRevenue;
+ totalCost += rTotalCost;
+ activeRiders++;
+
+ return {
+ ...r,
+ kms: rKms,
+ revenue: rRevenue,
+ varCost: rVarCost,
+ fixedCost: rFixedCost,
+ totalCost: rTotalCost,
+ net: rNet,
+ margin: rMargin
+ };
+ })
+ .filter(Boolean);
+
+ const totalNet = totalRevenue - totalCost;
+ const totalMargin = totalRevenue > 0 ? (totalNet / totalRevenue) * 100 : 0;
+
+ return {
+ activeRiders,
+ totalOrders,
+ totalRevenue,
+ totalCost,
+ totalNet,
+ totalMargin,
+ profitableRiders,
+ lossRiders,
+ enrichedRiders: list
+ };
+ }, [ridersList, customBatches]);
+
+ // Filter riders by search query
+ const filteredRiders = useMemo(() => {
+ if (!stats?.enrichedRiders || !Array.isArray(stats.enrichedRiders)) return [];
+ const baseList = stats.enrichedRiders.filter(Boolean);
+ if (!debouncedSearch) return baseList;
+ const q = debouncedSearch.toLowerCase().trim();
+ return baseList.filter(
+ (r) => r && [r.riderName, String(r.id)].filter(Boolean).some((field) => String(field).toLowerCase().includes(q))
+ );
+ }, [stats?.enrichedRiders, debouncedSearch]);
+
+ const KPI_META = [
+ {
+ key: 'riders',
+ label: 'Riders Active',
+ color: BRAND,
+ icon: MdPerson,
+ value: stats?.activeRiders ?? 0,
+ detail: `${stats?.profitableRiders ?? 0} in profit · ${stats?.lossRiders ?? 0} at loss`
+ },
+ {
+ key: 'revenue',
+ label: 'Slot Revenue',
+ color: '#0ea5e9',
+ icon: MdOutlineLocalShipping,
+ value: formatNumberToRupees(stats?.totalRevenue ?? 0),
+ detail: `From ${stats?.totalOrders ?? 0} orders`
+ },
+ {
+ key: 'cost',
+ label: 'Slot Cost',
+ color: '#f59e0b',
+ icon: MdPayments,
+ value: formatNumberToRupees(stats?.totalCost ?? 0),
+ detail: 'Fixed + variable'
+ },
+ {
+ key: 'net',
+ label: 'Slot Net',
+ color: (stats?.totalNet ?? 0) >= 0 ? '#10b981' : '#ef4444',
+ icon: (stats?.totalNet ?? 0) >= 0 ? MdTrendingUp : MdTrendingDown,
+ value: `${(stats?.totalNet ?? 0) >= 0 ? '+' : ''}${formatNumberToRupees(stats?.totalNet ?? 0)}`,
+ detail: `${(stats?.totalRevenue ?? 0) > 0 ? ((stats?.totalNet ?? 0) / (stats?.totalRevenue ?? 1) >= 0 ? '+' : '') : ''}${(
+ stats?.totalMargin ?? 0
+ ).toFixed(0)}% margin`
+ }
+ ];
+
+ return (
+ <>
+ {(isLoadingDeliveries || isFetchingNextPage) && }
+
+ {/* Page Header */}
+ }
+ placeholder="Select Zone"
+ paperComponent={SoftPaper}
+ sx={{ width: { xs: '100%', sm: 280 }, zIndex: 100 }}
+ />
+ }
+ />
+
+ {/* KPI Cards Grid */}
+
+ {KPI_META.map((item) => {
+ const Icon = item.icon;
+ return (
+
+ }
+ color={item.color}
+ loading={isLoadingDeliveries}
+ />
+
+ {item.detail}
+
+
+ );
+ })}
+
+
+ {/* Filter Bar (date + search) */}
+
+
+
+
+
+
+
+
+ Profitability Overview · {datestatus}
+
+
+ {filteredRiders.length} riders · {stats.profitableRiders} profitable · {stats.lossRiders} at loss
+
+
+
+ setOpen(true)}
+ sx={{
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: 0.75,
+ px: 1.25,
+ py: 0.75,
+ borderRadius: 999,
+ cursor: 'pointer',
+ bgcolor: tint('#f59e0b'),
+ border: `1.5px solid ${edge('#f59e0b')}`,
+ color: '#f59e0b',
+ fontWeight: 800,
+ fontSize: 12,
+ ml: 1,
+ transition: 'all 0.18s',
+ '&:hover': { borderColor: '#f59e0b', boxShadow: `0 0 0 3px ${ring('#f59e0b')}` }
+ }}
+ >
+
+ {dayjs(startdate).format('DD/MM/YY')} – {dayjs(enddate).format('DD/MM/YY')}
+
+
+
+
+
+
+
+
+
+ {/* Table & Mobile List Container */}
+
+ {isMobile ? (
+
+ {!filteredRiders || filteredRiders.length === 0 ? (
+
+
+
+
+
+ No riders to show
+
+
+ ) : (
+ filteredRiders.map((row, index) => {
+ if (!row) return null;
+ const isProfit = (row.net ?? 0) >= 0;
+ return (
+
+
+
+
+
+
+ {row.riderName}
+
+
+ ID #{row.id}
+
+
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+ );
+ })
+ )}
+
+ ) : (
+
+
+
+
+ #
+ Rider
+ Orders
+ Distance
+ Revenue
+ Fixed Cost
+ Variable Cost
+ Total Cost
+ Net Profit
+ Margin
+
+
+
+ {!filteredRiders || filteredRiders.length === 0 ? (
+
+
+
+
+
+
+
+ No riders to show
+
+
+
+
+ ) : (
+ filteredRiders.map((row, index) => {
+ if (!row) return null;
+ const isProfit = (row.net ?? 0) >= 0;
+ return (
+
+
+
+ {String(index + 1).padStart(2, '0')}
+
+
+
+
+
+
+
+
+
+ {row.riderName}
+
+
+ ID #{row.id}
+
+
+
+
+
+
+ {row.orders.length}
+
+
+
+ } label={`${row.kms.toFixed(2)} km`} tooltip="KMS" />
+
+
+ }
+ label={formatNumberToRupees(row.revenue).replace('₹', '').trim()}
+ tooltip="Revenue"
+ />
+
+
+ }
+ label={formatNumberToRupees(row.fixedCost).replace('₹', '').trim()}
+ tooltip="Fixed Cost"
+ />
+
+
+ }
+ label={formatNumberToRupees(row.varCost).replace('₹', '').trim()}
+ tooltip="Variable Cost"
+ />
+
+
+ }
+ label={formatNumberToRupees(row.totalCost).replace('₹', '').trim()}
+ tooltip="Total Cost"
+ />
+
+
+ : }
+ label={`${isProfit ? '+' : ''}${formatNumberToRupees(row.net).replace('₹', '').trim()}`}
+ tooltip="Net Profit"
+ />
+
+
+
+
+
+ );
+ })
+ )}
+
+
+
+ )}
+
+
+ {/* Date Filter Dialog */}
+ setOpen(false)}
+ onSelect={(range) => {
+ setStartdate(range.startDate);
+ setEnddate(range.endDate);
+ setDatestatus(range.label);
+ }}
+ />
+ >
+ );
+}
diff --git a/src/routes/MainRoutes.js b/src/routes/MainRoutes.js
index 276957b..b6d8acb 100644
--- a/src/routes/MainRoutes.js
+++ b/src/routes/MainRoutes.js
@@ -44,6 +44,7 @@ const OrdersSummary = Loadable(lazy(() => import('pages/nearle/reports/ordersSum
const OrdersDetails = Loadable(lazy(() => import('pages/nearle/reports/ordersDetails')));
const RidersSummary = Loadable(lazy(() => import('pages/nearle/reports/ridersSummary')));
const RidersLogs = Loadable(lazy(() => import('pages/nearle/reports/ridersLogs')));
+const Profitability = Loadable(lazy(() => import('pages/nearle/reports/profitability')));
const Riders = Loadable(lazy(() => import('pages/nearle/riders/riders')));
const Createrider = Loadable(lazy(() => import('pages/nearle/riders/createrider')));
@@ -165,6 +166,10 @@ const MainRoutes = {
{
path: 'riderslogs',
element:
+ },
+ {
+ path: 'profitability',
+ element:
}
]
},
diff --git a/src/utils/locales/en.json b/src/utils/locales/en.json
index 5ac473c..c8cc420 100644
--- a/src/utils/locales/en.json
+++ b/src/utils/locales/en.json
@@ -17,5 +17,6 @@
"riderssummary": "Riders Summary",
"riderslogs": "Riders Logs",
"invoice": "Invoice",
- "dispatch": "Dispatch"
+ "dispatch": "Dispatch",
+ "profitability": "Profitability"
}