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" }