import React, { useState, useMemo, useCallback } from 'react';
import PropTypes from 'prop-types';
import {
MdTrendingUp,
MdTrendingDown,
MdExpandMore,
MdReceipt,
MdPayments,
MdRoute,
MdLocationOn,
MdBarChart,
MdPeopleAlt
} from 'react-icons/md';
import './ProfitabilitySection.css';
// ─────────────────────────────────────────────────────────────
// Constants
// ─────────────────────────────────────────────────────────────
/** Revenue rule: ₹30 base for ≤8 km, ₹6/km beyond. */
const BASE_REVENUE = 30;
const BASE_KM_LIMIT = 8;
const EXTRA_RATE_KM = 6;
/** Fixed salary cost sliced per slot (₹5000 / 30 days / 1 slot). */
const FIXED_COST_PER_SLOT = 166.67;
/** Variable fuel / wear cost per km. */
const VARIABLE_RATE_KM = 2.5;
/** Status display config keyed by normalised status string. */
const STATUS_MAP = {
delivered: { label: 'Delivered', color: '#10b981', bg: '#ecfdf5', border: '#a7f3d0' },
active: { label: 'Active', color: '#0ea5e9', bg: '#f0f9ff', border: '#bae6fd' },
picked: { label: 'Picked up', color: '#8b5cf6', bg: '#f5f3ff', border: '#ddd6fe' },
assigned: { label: 'Assigned', color: '#f59e0b', bg: '#fffbeb', border: '#fde68a' },
cancelled: { label: 'Cancelled', color: '#ef4444', bg: '#fef2f2', border: '#fca5a5' },
skipped: { label: 'Skipped', color: '#f97316', bg: '#fff7ed', border: '#ffedd5' }
};
const DEFAULT_STATUS = STATUS_MAP.assigned;
// ─────────────────────────────────────────────────────────────
// Pure helpers
// ─────────────────────────────────────────────────────────────
function getStatusConfig(raw) {
return STATUS_MAP[String(raw ?? '').toLowerCase()] ?? DEFAULT_STATUS;
}
function orderRevenue(order) {
const km = parseFloat(order.kms || order.actualkms || 0);
return km <= BASE_KM_LIMIT ? BASE_REVENUE : BASE_REVENUE + (km - BASE_KM_LIMIT) * EXTRA_RATE_KM;
}
function calcRiderMetrics(rider) {
const orders = rider.orders ?? [];
let revenue = 0;
let kms = 0;
for (const o of orders) {
revenue += orderRevenue(o);
kms += parseFloat(o.kms || o.actualkms || 0);
}
const varCost = kms * VARIABLE_RATE_KM;
const fixedCost = FIXED_COST_PER_SLOT;
const totalCost = varCost + fixedCost;
const net = revenue - totalCost;
const margin = revenue > 0 ? (net / revenue) * 100 : 0;
return { revenue, kms, varCost, fixedCost, totalCost, net, margin };
}
function rupees(v, decimals = 0) {
if (v == null) return '—';
return `₹${parseFloat(v).toFixed(decimals)}`;
}
function riderInitials(name) {
if (!name) return '?';
return (
name
.trim()
.split(/\s+/)
.slice(0, 2)
.map((w) => w[0] ?? '')
.join('')
.toUpperCase() || '?'
);
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
// ─────────────────────────────────────────────────────────────
// Sub-components
// ─────────────────────────────────────────────────────────────
/** Coloured pill with a status dot. */
function OrderStatusPill({ status }) {
const cfg = getStatusConfig(status);
return (
{cfg.label}
);
}
/** Customer name + phone + location stacked cell. */
function CustomerCell({ order }) {
const name = order.customername ?? order.customer_name ?? order.deliverycustomer ?? order.pickupcustomer ?? 'Unknown customer';
const phone = order.customerphone ?? order.phone ?? order.mobile ?? '';
const location = order.locationname ?? order.kitchenname ?? order.kitchenName ?? '';
return (
{name}
{phone &&
{phone}
}
{location && (
{location}
)}
);
}
/** Orders table inside an expanded rider card. */
function OrdersBreakdownTable({ orders, getRevenue }) {
if (!orders?.length) {
return (
No orders assigned to this rider yet.
);
}
return (
| Customer |
Status |
Distance |
Revenue
|
{orders.map((order, idx) => {
const km = parseFloat(order.kms ?? order.actualkms ?? 0);
const rev = getRevenue(order);
return (
|
|
|
{km.toFixed(1)}
km
|
{rupees(rev)}
|
);
})}
);
}
/** Expanded cost breakdown + orders for one rider. */
function RiderDetailPanel({ rider, metrics }) {
const { varCost, fixedCost, kms, net, margin } = metrics;
const isProfit = net >= 0;
const costTiles = [
{
icon: ,
iconClass: 'cost-tile-icon--fixed',
label: 'Fixed cost',
amount: rupees(fixedCost, 2),
amountClass: '',
footnote: 'Salary (per slot)'
},
{
icon: ,
iconClass: 'cost-tile-icon--variable',
label: 'Variable cost',
amount: rupees(varCost, 2),
amountClass: '',
footnote: `${kms.toFixed(1)} km × ₹${VARIABLE_RATE_KM}/km`
},
{
icon: isProfit ? : ,
iconClass: isProfit ? 'cost-tile-icon--profit' : 'cost-tile-icon--loss',
label: isProfit ? 'Net profit' : 'Net loss',
amount: `${isProfit ? '+' : ''}${rupees(net, 2)}`,
amountClass: isProfit ? 'cost-tile-amount--profit' : 'cost-tile-amount--loss',
footnote: `${Math.abs(margin).toFixed(0)}% margin`
}
];
return (
{/* Cost breakdown tiles */}
{costTiles.map((tile) => (
{tile.icon}
{tile.label}
{tile.amount}
{tile.footnote}
))}
{/* Revenue breakdown table */}
Revenue Breakdown
{rider.orders?.length ?? 0} order{(rider.orders?.length ?? 0) !== 1 ? 's' : ''}
);
}
/** Single expandable rider profitability card. */
function RiderProfitabilityCard({ rider, metrics, isExpanded, isFocused, onToggle, onFocus }) {
const { revenue, kms, totalCost, net, margin } = metrics;
const isProfit = net >= 0;
const marginBarWidth = `${clamp(Math.abs(margin), 0, 100)}%`;
const orderCount = rider.orders?.length ?? 0;
const name = rider.riderName ?? rider.username ?? `Rider #${rider.id}`;
const cardClasses = [
'rider-profitability-card',
isProfit ? 'rider-profitability-card--profitable' : 'rider-profitability-card--unprofitable',
isFocused ? 'rider-profitability-card--selected' : ''
]
.filter(Boolean)
.join(' ');
function handleActivate() {
onToggle(rider.id);
if (!isFocused && onFocus) onFocus(rider);
}
return (
{/* Clickable header row */}
{
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleActivate();
}
}}
>
{/* Rider identity */}
{riderInitials(name)}
{name}
{orderCount} order{orderCount !== 1 ? 's' : ''}
·
{kms.toFixed(1)} km total
{/* Summary metrics — hidden on mobile via CSS */}
Revenue
{rupees(revenue)}
Cost
{rupees(totalCost)}
Net
{isProfit ? '+' : ''}
{rupees(net)}
{/* Expand chevron */}
{/* Expanded detail panel */}
{isExpanded &&
}
);
}
// ─────────────────────────────────────────────────────────────
// Main export
// ─────────────────────────────────────────────────────────────
/**
* ProfitabilitySection
*
* Props:
* riders — Array of rider objects with .orders[]
* totalDailyProfit — Number: daily aggregate across all slots
* focusedRider — Rider object (or null) synced with map
* handleRiderFocus — (rider) => void called when a card is clicked
*/
export default function ProfitabilitySection({ riders = [], totalDailyProfit = 0, focusedRider = null, handleRiderFocus }) {
const [expanded, setExpanded] = useState({});
const [sortMode, setSortMode] = useState('profit-asc'); // 'profit-asc', 'profit-desc', 'name-asc', 'orders-desc'
const toggleRider = useCallback((id) => {
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
}, []);
// Enrich riders with computed metrics
const enriched = useMemo(() => riders.map((r) => ({ ...r, _m: calcRiderMetrics(r) })), [riders]);
// Aggregate overall slot totals (pre-filtered for BI consistency)
const slotRevenue = enriched.reduce((s, r) => s + r._m.revenue, 0);
const slotCost = enriched.reduce((s, r) => s + r._m.totalCost, 0);
const slotNet = enriched.reduce((s, r) => s + r._m.net, 0);
const profitCount = enriched.filter((r) => r._m.net >= 0).length;
const lossCount = enriched.length - profitCount;
const dailyIsProfit = totalDailyProfit >= 0;
const slotIsProfit = slotNet >= 0;
// Filter riders (filtering by search and status tabs removed)
const filtered = enriched;
// Sort riders based on selected sortMode
const sortedAndFiltered = useMemo(() => {
const list = [...filtered];
if (sortMode === 'profit-asc') {
list.sort((a, b) => a._m.net - b._m.net);
} else if (sortMode === 'profit-desc') {
list.sort((a, b) => b._m.net - a._m.net);
} else if (sortMode === 'name-asc') {
list.sort((a, b) => {
const nameA = (a.riderName ?? a.username ?? `Rider #${a.id}`).toLowerCase();
const nameB = (b.riderName ?? b.username ?? `Rider #${b.id}`).toLowerCase();
return nameA.localeCompare(nameB);
});
} else if (sortMode === 'orders-desc') {
list.sort((a, b) => (b.orders?.length ?? 0) - (a.orders?.length ?? 0));
}
return list;
}, [filtered, sortMode]);
return (
{/* ── Header ── */}
{/* ── Summary statistics row ── */}
Riders Active
{enriched.length}
{profitCount} in profit · {lossCount} at loss
Slot Revenue
{rupees(slotRevenue)}
From {enriched.reduce((s, r) => s + (r.orders?.length ?? 0), 0)} orders
Slot Cost
{rupees(slotCost)}
Fixed + variable
Slot Net
{slotIsProfit ? '+' : ''}
{rupees(slotNet)}
0 ? (slotNet / slotRevenue >= 0 ? 'margin-positive' : 'margin-negative') : ''
}`}
>
{slotRevenue > 0
? `${slotNet / slotRevenue >= 0 ? '+' : ''}${((slotNet / slotRevenue) * 100).toFixed(0)}% margin`
: '0% margin'}
{/* ── Controls (Search) (removed) ── */}
{/* ── Rider feed ── */}
Riders List
{sortedAndFiltered.length === 0 ? (
No riders matching the filters.
) : (
sortedAndFiltered.map((rider) => (
))
)}
);
}
OrderStatusPill.propTypes = {
status: PropTypes.string
};
CustomerCell.propTypes = {
order: PropTypes.object.isRequired
};
OrdersBreakdownTable.propTypes = {
orders: PropTypes.array,
getRevenue: PropTypes.func.isRequired
};
RiderDetailPanel.propTypes = {
rider: PropTypes.object.isRequired,
metrics: PropTypes.shape({
varCost: PropTypes.number.isRequired,
fixedCost: PropTypes.number.isRequired,
kms: PropTypes.number.isRequired,
net: PropTypes.number.isRequired,
margin: PropTypes.number.isRequired
}).isRequired
};
RiderProfitabilityCard.propTypes = {
rider: PropTypes.object.isRequired,
metrics: PropTypes.shape({
revenue: PropTypes.number.isRequired,
kms: PropTypes.number.isRequired,
totalCost: PropTypes.number.isRequired,
net: PropTypes.number.isRequired,
margin: PropTypes.number.isRequired
}).isRequired,
isExpanded: PropTypes.bool.isRequired,
isFocused: PropTypes.bool,
onToggle: PropTypes.func.isRequired,
onFocus: PropTypes.func
};
ProfitabilitySection.propTypes = {
riders: PropTypes.array,
totalDailyProfit: PropTypes.number,
focusedRider: PropTypes.object,
handleRiderFocus: PropTypes.func
};