updates on the profitability page and added the new page of this one
This commit is contained in:
@@ -149,6 +149,13 @@ const nearle = {
|
|||||||
url: '/nearle/reports/riderslogs',
|
url: '/nearle/reports/riderslogs',
|
||||||
icon: DirectionsBikeOutlinedIcon
|
icon: DirectionsBikeOutlinedIcon
|
||||||
// target: true
|
// target: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'profitability',
|
||||||
|
title: <FormattedMessage id="profitability" />,
|
||||||
|
type: 'item',
|
||||||
|
url: '/nearle/reports/profitability',
|
||||||
|
icon: icons.BarChartOutlined
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ function getStatusConfig(raw) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function orderRevenue(order) {
|
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;
|
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) {
|
for (const o of orders) {
|
||||||
revenue += orderRevenue(o);
|
revenue += orderRevenue(o);
|
||||||
kms += parseFloat(o.kms ?? o.actualkms ?? 0);
|
kms += parseFloat(o.kms || o.actualkms || 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const varCost = kms * VARIABLE_RATE_KM;
|
const varCost = kms * VARIABLE_RATE_KM;
|
||||||
@@ -472,8 +472,13 @@ export default function ProfitabilitySection({ riders = [], totalDailyProfit = 0
|
|||||||
{slotIsProfit ? '+' : ''}
|
{slotIsProfit ? '+' : ''}
|
||||||
{rupees(slotNet)}
|
{rupees(slotNet)}
|
||||||
</span>
|
</span>
|
||||||
<span className={`profitability-summary-detail ${slotRevenue > 0 ? (slotNet / slotRevenue >= 0 ? 'margin-positive' : 'margin-negative') : ''}`}>
|
<span
|
||||||
{slotRevenue > 0 ? `${slotNet / slotRevenue >= 0 ? '+' : ''}${((slotNet / slotRevenue) * 100).toFixed(0)}% margin` : '0% margin'}
|
className={`profitability-summary-detail ${slotRevenue > 0 ? (slotNet / slotRevenue >= 0 ? 'margin-positive' : 'margin-negative') : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{slotRevenue > 0
|
||||||
|
? `${slotNet / slotRevenue >= 0 ? '+' : ''}${((slotNet / slotRevenue) * 100).toFixed(0)}% margin`
|
||||||
|
: '0% margin'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
773
src/pages/nearle/reports/profitability.js
Normal file
773
src/pages/nearle/reports/profitability.js
Normal file
@@ -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) => (
|
||||||
|
<Paper
|
||||||
|
{...props}
|
||||||
|
sx={{
|
||||||
|
mt: 0.75,
|
||||||
|
borderRadius: 2,
|
||||||
|
boxShadow: DT.shadowPop,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
SoftPaper.propTypes = {
|
||||||
|
children: PropTypes.node
|
||||||
|
};
|
||||||
|
|
||||||
|
const AccentAvatar = ({ color, selected, size = 24, children }) => (
|
||||||
|
<Avatar
|
||||||
|
sx={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
bgcolor: selected ? color : soft(color),
|
||||||
|
color: selected ? '#fff' : color,
|
||||||
|
transition: 'background-color 0.15s, color 0.15s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Avatar>
|
||||||
|
);
|
||||||
|
|
||||||
|
AccentAvatar.propTypes = {
|
||||||
|
color: PropTypes.string.isRequired,
|
||||||
|
selected: PropTypes.bool,
|
||||||
|
size: PropTypes.number,
|
||||||
|
children: PropTypes.node
|
||||||
|
};
|
||||||
|
|
||||||
|
const MetricPill = ({ color, icon, label, tooltip, minWidth = 80 }) => (
|
||||||
|
<Tooltip title={tooltip || ''} placement="top">
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.5,
|
||||||
|
px: 1,
|
||||||
|
py: 0.375,
|
||||||
|
borderRadius: 999,
|
||||||
|
bgcolor: '#ffffff',
|
||||||
|
border: `1px solid ${edge(color)}`,
|
||||||
|
color,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 800,
|
||||||
|
minWidth,
|
||||||
|
justifyContent: 'center',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
|
||||||
|
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) && <Loader />}
|
||||||
|
|
||||||
|
{/* Page Header */}
|
||||||
|
<PageHeader
|
||||||
|
title="Profitability Report"
|
||||||
|
subtitle={`Live · ${locaName || 'All Zones'} · ${datestatus}`}
|
||||||
|
live
|
||||||
|
action={
|
||||||
|
<LocationAutocomplete
|
||||||
|
locaName={locaName}
|
||||||
|
setAppId={setAppId}
|
||||||
|
setLocoName={setLocoName}
|
||||||
|
pill
|
||||||
|
accentColor={BRAND}
|
||||||
|
icon={<MdMyLocation size={14} />}
|
||||||
|
placeholder="Select Zone"
|
||||||
|
paperComponent={SoftPaper}
|
||||||
|
sx={{ width: { xs: '100%', sm: 280 }, zIndex: 100 }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* KPI Cards Grid */}
|
||||||
|
<Grid container spacing={{ xs: 2, md: 2.5 }} sx={{ mb: { xs: 1.5, md: 2 } }}>
|
||||||
|
{KPI_META.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<Grid item key={item.key} xs={6} sm={6} md={3}>
|
||||||
|
<StatCard
|
||||||
|
title={item.label}
|
||||||
|
value={item.value ?? 0}
|
||||||
|
icon={<Icon size={20} />}
|
||||||
|
color={item.color}
|
||||||
|
loading={isLoadingDeliveries}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" sx={{ color: DT.textSecondary, display: 'block', mt: 0.5, px: 2, fontWeight: 500 }}>
|
||||||
|
{item.detail}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Filter Bar (date + search) */}
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
mt: { xs: 1.5, md: 2 },
|
||||||
|
p: { xs: 1, md: 1.5 },
|
||||||
|
borderTopLeftRadius: DT.radiusCard / 8,
|
||||||
|
borderTopRightRadius: DT.radiusCard / 8,
|
||||||
|
borderBottomLeftRadius: 0,
|
||||||
|
borderBottomRightRadius: 0,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: DT.borderSubtle,
|
||||||
|
borderBottom: 0,
|
||||||
|
background: '#fff'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: 'column', sm: 'row' }}
|
||||||
|
alignItems={{ xs: 'stretch', sm: 'center' }}
|
||||||
|
justifyContent="space-between"
|
||||||
|
spacing={1.25}
|
||||||
|
>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1.25} flexWrap="wrap">
|
||||||
|
<AccentAvatar color={BRAND} size={32}>
|
||||||
|
<MdPerson size={18} />
|
||||||
|
</AccentAvatar>
|
||||||
|
<Stack>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ fontWeight: 800, color: DT.textSecondary, letterSpacing: 0.6, textTransform: 'uppercase' }}
|
||||||
|
>
|
||||||
|
Profitability Overview · {datestatus}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: DT.textPrimary, fontWeight: 700 }}>
|
||||||
|
{filteredRiders.length} riders · {stats.profitableRiders} profitable · {stats.lossRiders} at loss
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Tooltip title="Date Filter" placement="top">
|
||||||
|
<Box
|
||||||
|
onClick={() => 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')}` }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MdCalendarMonth size={14} />
|
||||||
|
{dayjs(startdate).format('DD/MM/YY')} – {dayjs(enddate).format('DD/MM/YY')}
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
<Box sx={{ width: { xs: '100%', sm: 280, lg: 340 }, flex: { xs: '1 1 100%', sm: '0 0 auto' } }}>
|
||||||
|
<DebounceSearchBar
|
||||||
|
value={searchword}
|
||||||
|
onChange={setSearchword}
|
||||||
|
onDebouncedChange={setDebouncedSearch}
|
||||||
|
placeholder="Search riders"
|
||||||
|
sx={{
|
||||||
|
m: 0,
|
||||||
|
width: '100%',
|
||||||
|
borderRadius: 999,
|
||||||
|
bgcolor: '#ffffff',
|
||||||
|
'& fieldset': { borderColor: '#e2e8f0', borderWidth: 1 },
|
||||||
|
'&:hover fieldset': { borderColor: '#cbd5e1' },
|
||||||
|
'&.Mui-focused fieldset': { borderColor: '#662582', borderWidth: 1.5 },
|
||||||
|
'&.Mui-focused': { boxShadow: `0 0 0 3px ${ring(BRAND)}` }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Table & Mobile List Container */}
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
borderTopLeftRadius: 0,
|
||||||
|
borderTopRightRadius: 0,
|
||||||
|
borderBottomLeftRadius: DT.radiusCard / 8,
|
||||||
|
borderBottomRightRadius: DT.radiusCard / 8,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: DT.borderSubtle,
|
||||||
|
overflow: 'hidden',
|
||||||
|
background: '#fff'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isMobile ? (
|
||||||
|
<MobileCardList scroll>
|
||||||
|
{!filteredRiders || filteredRiders.length === 0 ? (
|
||||||
|
<Stack alignItems="center" spacing={1.5} sx={{ py: 6 }}>
|
||||||
|
<Avatar sx={{ width: 64, height: 64, bgcolor: soft('#94a3b8'), color: DT.textMuted }}>
|
||||||
|
<MdPerson size={28} />
|
||||||
|
</Avatar>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: DT.textPrimary }}>
|
||||||
|
No riders to show
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
filteredRiders.map((row, index) => {
|
||||||
|
if (!row) return null;
|
||||||
|
const isProfit = (row.net ?? 0) >= 0;
|
||||||
|
return (
|
||||||
|
<MobileCard
|
||||||
|
key={row.id || index}
|
||||||
|
accent={isProfit ? '#10b981' : '#ef4444'}
|
||||||
|
header={
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1}>
|
||||||
|
<AccentAvatar color={isProfit ? '#10b981' : '#ef4444'} size={36}>
|
||||||
|
<MdPerson size={18} />
|
||||||
|
</AccentAvatar>
|
||||||
|
<Stack>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary }}>
|
||||||
|
{row.riderName}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
|
||||||
|
ID #{row.id}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MobileFieldGrid columns={2}>
|
||||||
|
<MobileField label="Orders" value={row.orders.length} />
|
||||||
|
<MobileField label="Distance" value={`${row.kms.toFixed(2)} km`} />
|
||||||
|
<MobileField label="Revenue" value={formatNumberToRupees(row.revenue)} />
|
||||||
|
<MobileField label="Fixed Cost" value={formatNumberToRupees(row.fixedCost)} />
|
||||||
|
<MobileField label="Variable Cost" value={formatNumberToRupees(row.varCost)} />
|
||||||
|
<MobileField label="Total Cost" value={formatNumberToRupees(row.totalCost)} />
|
||||||
|
<MobileField label="Net Profit" value={`${isProfit ? '+' : ''}${formatNumberToRupees(row.net)}`} full />
|
||||||
|
<MobileField label="Margin" value={`${Math.abs(row.margin).toFixed(0)}%`} full />
|
||||||
|
</MobileFieldGrid>
|
||||||
|
</MobileCard>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</MobileCardList>
|
||||||
|
) : (
|
||||||
|
<TableContainer
|
||||||
|
sx={{
|
||||||
|
maxHeight: 'calc(100vh - 280px)',
|
||||||
|
'&::-webkit-scrollbar': { width: 10, height: 10 },
|
||||||
|
'&::-webkit-scrollbar-thumb': {
|
||||||
|
backgroundColor: edge(BRAND),
|
||||||
|
borderRadius: 8,
|
||||||
|
'&:hover': { backgroundColor: BRAND }
|
||||||
|
},
|
||||||
|
'&::-webkit-scrollbar-track': { backgroundColor: DT.surfaceAlt }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Table stickyHeader sx={{ minWidth: 1000 }}>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow
|
||||||
|
sx={{
|
||||||
|
'& th': {
|
||||||
|
backgroundColor: DT.surfaceAlt,
|
||||||
|
color: DT.textSecondary,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 800,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
borderBottom: `1px solid ${DT.borderSubtle}`,
|
||||||
|
py: 1.25,
|
||||||
|
px: 2
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TableCell>#</TableCell>
|
||||||
|
<TableCell>Rider</TableCell>
|
||||||
|
<TableCell align="center">Orders</TableCell>
|
||||||
|
<TableCell align="center">Distance</TableCell>
|
||||||
|
<TableCell align="center">Revenue</TableCell>
|
||||||
|
<TableCell align="center">Fixed Cost</TableCell>
|
||||||
|
<TableCell align="center">Variable Cost</TableCell>
|
||||||
|
<TableCell align="center">Total Cost</TableCell>
|
||||||
|
<TableCell align="center">Net Profit</TableCell>
|
||||||
|
<TableCell align="center">Margin</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{!filteredRiders || filteredRiders.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={10} sx={{ py: 6 }}>
|
||||||
|
<Stack alignItems="center" spacing={1.5}>
|
||||||
|
<Avatar sx={{ width: 64, height: 64, bgcolor: soft('#94a3b8'), color: DT.textMuted }}>
|
||||||
|
<MdPerson size={28} />
|
||||||
|
</Avatar>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: DT.textPrimary }}>
|
||||||
|
No riders to show
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
filteredRiders.map((row, index) => {
|
||||||
|
if (!row) return null;
|
||||||
|
const isProfit = (row.net ?? 0) >= 0;
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={row.id || index}
|
||||||
|
sx={{
|
||||||
|
transition: 'background-color 0.15s',
|
||||||
|
'& td': {
|
||||||
|
borderBottom: `1px solid ${DT.divider}`,
|
||||||
|
py: 1.5,
|
||||||
|
px: 2
|
||||||
|
},
|
||||||
|
'&:hover': { backgroundColor: DT.surfaceAlt }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: DT.textMuted }}>
|
||||||
|
{String(index + 1).padStart(2, '0')}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1}>
|
||||||
|
<AccentAvatar color={BRAND} size={36}>
|
||||||
|
<MdPerson size={18} />
|
||||||
|
</AccentAvatar>
|
||||||
|
<Stack>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap' }}>
|
||||||
|
{row.riderName}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
|
||||||
|
ID #{row.id}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="center">
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700, color: DT.textPrimary }}>
|
||||||
|
{row.orders.length}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="center">
|
||||||
|
<MetricPill color="#10b981" icon={<MdStraighten size={11} />} label={`${row.kms.toFixed(2)} km`} tooltip="KMS" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="center">
|
||||||
|
<MetricPill
|
||||||
|
color={BRAND}
|
||||||
|
icon={<MdOutlineCurrencyRupee size={11} />}
|
||||||
|
label={formatNumberToRupees(row.revenue).replace('₹', '').trim()}
|
||||||
|
tooltip="Revenue"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="center">
|
||||||
|
<MetricPill
|
||||||
|
color="#6366f1"
|
||||||
|
icon={<MdPayments size={11} />}
|
||||||
|
label={formatNumberToRupees(row.fixedCost).replace('₹', '').trim()}
|
||||||
|
tooltip="Fixed Cost"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="center">
|
||||||
|
<MetricPill
|
||||||
|
color="#f59e0b"
|
||||||
|
icon={<MdRoute size={11} />}
|
||||||
|
label={formatNumberToRupees(row.varCost).replace('₹', '').trim()}
|
||||||
|
tooltip="Variable Cost"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="center">
|
||||||
|
<MetricPill
|
||||||
|
color="#94a3b8"
|
||||||
|
icon={<MdPayments size={11} />}
|
||||||
|
label={formatNumberToRupees(row.totalCost).replace('₹', '').trim()}
|
||||||
|
tooltip="Total Cost"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="center">
|
||||||
|
<MetricPill
|
||||||
|
color={isProfit ? '#10b981' : '#ef4444'}
|
||||||
|
icon={isProfit ? <MdTrendingUp size={11} /> : <MdTrendingDown size={11} />}
|
||||||
|
label={`${isProfit ? '+' : ''}${formatNumberToRupees(row.net).replace('₹', '').trim()}`}
|
||||||
|
tooltip="Net Profit"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="center">
|
||||||
|
<Chip
|
||||||
|
label={`${Math.abs(row.margin).toFixed(0)}%`}
|
||||||
|
color={isProfit ? 'success' : 'error'}
|
||||||
|
size="small"
|
||||||
|
sx={{ fontWeight: 700, minWidth: 60 }}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Date Filter Dialog */}
|
||||||
|
<DateFilterDialog
|
||||||
|
open={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
onSelect={(range) => {
|
||||||
|
setStartdate(range.startDate);
|
||||||
|
setEnddate(range.endDate);
|
||||||
|
setDatestatus(range.label);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ const OrdersSummary = Loadable(lazy(() => import('pages/nearle/reports/ordersSum
|
|||||||
const OrdersDetails = Loadable(lazy(() => import('pages/nearle/reports/ordersDetails')));
|
const OrdersDetails = Loadable(lazy(() => import('pages/nearle/reports/ordersDetails')));
|
||||||
const RidersSummary = Loadable(lazy(() => import('pages/nearle/reports/ridersSummary')));
|
const RidersSummary = Loadable(lazy(() => import('pages/nearle/reports/ridersSummary')));
|
||||||
const RidersLogs = Loadable(lazy(() => import('pages/nearle/reports/ridersLogs')));
|
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 Riders = Loadable(lazy(() => import('pages/nearle/riders/riders')));
|
||||||
const Createrider = Loadable(lazy(() => import('pages/nearle/riders/createrider')));
|
const Createrider = Loadable(lazy(() => import('pages/nearle/riders/createrider')));
|
||||||
@@ -165,6 +166,10 @@ const MainRoutes = {
|
|||||||
{
|
{
|
||||||
path: 'riderslogs',
|
path: 'riderslogs',
|
||||||
element: <RidersLogs />
|
element: <RidersLogs />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'profitability',
|
||||||
|
element: <Profitability />
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,5 +17,6 @@
|
|||||||
"riderssummary": "Riders Summary",
|
"riderssummary": "Riders Summary",
|
||||||
"riderslogs": "Riders Logs",
|
"riderslogs": "Riders Logs",
|
||||||
"invoice": "Invoice",
|
"invoice": "Invoice",
|
||||||
"dispatch": "Dispatch"
|
"dispatch": "Dispatch",
|
||||||
|
"profitability": "Profitability"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user