Files
Doormilexpress_console/src/pages/nearle/dispatch/ProfitabilitySection.js

563 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<span className="order-status-pill" style={{ background: cfg.bg, color: cfg.color, borderColor: cfg.border }}>
<span className="order-status-dot" aria-hidden="true" />
{cfg.label}
</span>
);
}
/** 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 (
<div>
<div className="customer-name" title={name}>
{name}
</div>
{phone && <div className="customer-phone">{phone}</div>}
{location && (
<div className="customer-location">
<MdLocationOn size={12} aria-hidden="true" />
{location}
</div>
)}
</div>
);
}
/** Orders table inside an expanded rider card. */
function OrdersBreakdownTable({ orders, getRevenue }) {
if (!orders?.length) {
return (
<div className="orders-empty-state" role="status">
<MdReceipt size={24} aria-hidden="true" />
<p className="orders-empty-message">No orders assigned to this rider yet.</p>
</div>
);
}
return (
<div className="orders-table-container">
<table className="orders-table" role="table">
<thead>
<tr>
<th scope="col">Customer</th>
<th scope="col">Status</th>
<th scope="col">Distance</th>
<th scope="col" style={{ textAlign: 'right' }}>
Revenue
</th>
</tr>
</thead>
<tbody>
{orders.map((order, idx) => {
const km = parseFloat(order.kms ?? order.actualkms ?? 0);
const rev = getRevenue(order);
return (
<tr key={`${order.orderid ?? 'order'}-${idx}`}>
<td>
<CustomerCell order={order} />
</td>
<td>
<OrderStatusPill status={order.orderstatus ?? order.status} />
</td>
<td>
<span className="distance-value">{km.toFixed(1)}</span>
<span className="distance-unit">km</span>
</td>
<td style={{ textAlign: 'right' }}>
<span className="revenue-amount">{rupees(rev)}</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
/** 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: <MdPayments size={16} />,
iconClass: 'cost-tile-icon--fixed',
label: 'Fixed cost',
amount: rupees(fixedCost, 2),
amountClass: '',
footnote: 'Salary (per slot)'
},
{
icon: <MdRoute size={16} />,
iconClass: 'cost-tile-icon--variable',
label: 'Variable cost',
amount: rupees(varCost, 2),
amountClass: '',
footnote: `${kms.toFixed(1)} km ×${VARIABLE_RATE_KM}/km`
},
{
icon: isProfit ? <MdTrendingUp size={16} /> : <MdTrendingDown size={16} />,
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 (
<div className="rider-detail-panel" aria-label="Rider cost breakdown">
<div className="rider-detail-inner">
{/* Cost breakdown tiles */}
<div className="cost-breakdown-grid">
{costTiles.map((tile) => (
<div className="cost-tile" key={tile.label}>
<div className={`cost-tile-icon ${tile.iconClass}`} aria-hidden="true">
{tile.icon}
</div>
<div className="cost-tile-content">
<span className="cost-tile-label">{tile.label}</span>
<span className={`cost-tile-amount ${tile.amountClass}`}>{tile.amount}</span>
<span className="cost-tile-footnote">{tile.footnote}</span>
</div>
</div>
))}
</div>
{/* Revenue breakdown table */}
<div className="revenue-breakdown">
<div className="revenue-breakdown-header">
<div className="revenue-breakdown-title">
<MdReceipt size={14} aria-hidden="true" />
Revenue Breakdown
</div>
<span className="revenue-breakdown-count">
{rider.orders?.length ?? 0} order{(rider.orders?.length ?? 0) !== 1 ? 's' : ''}
</span>
</div>
<OrdersBreakdownTable orders={rider.orders} getRevenue={orderRevenue} />
</div>
</div>
</div>
);
}
/** 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 (
<div className={cardClasses} aria-label={`Rider: ${name}`}>
{/* Clickable header row */}
<div
className="rider-card-header"
role="button"
tabIndex={0}
aria-expanded={isExpanded}
onClick={handleActivate}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleActivate();
}
}}
>
{/* Rider identity */}
<div className="rider-avatar" aria-hidden="true">
{riderInitials(name)}
</div>
<div className="rider-identity">
<div className="rider-name" title={name}>
{name}
</div>
<div className="rider-order-summary">
{orderCount} order{orderCount !== 1 ? 's' : ''}
<span className="rider-order-summary-separator">·</span>
{kms.toFixed(1)} km total
</div>
</div>
{/* Summary metrics — hidden on mobile via CSS */}
<div className="rider-metrics-row" aria-hidden="true">
<div className="rider-metric">
<span className="rider-metric-label">Revenue</span>
<span className="rider-metric-amount rider-metric-amount--revenue">{rupees(revenue)}</span>
</div>
<div className="rider-metric">
<span className="rider-metric-label">Cost</span>
<span className="rider-metric-amount rider-metric-amount--cost">{rupees(totalCost)}</span>
</div>
<div className="rider-net-profit-column">
<div className="rider-metric">
<span className="rider-metric-label">Net</span>
<span className={`rider-metric-amount ${isProfit ? 'rider-metric-amount--net-profit' : 'rider-metric-amount--net-loss'}`}>
{isProfit ? '+' : ''}
{rupees(net)}
</span>
</div>
<div className="rider-margin-bar">
<div
className={`rider-margin-bar-fill ${isProfit ? 'rider-margin-bar-fill--profit' : 'rider-margin-bar-fill--loss'}`}
style={{ width: marginBarWidth }}
role="presentation"
/>
</div>
</div>
</div>
{/* Expand chevron */}
<div className={`rider-expand-toggle ${isExpanded ? 'rider-expand-toggle--open' : ''}`} aria-hidden="true">
<MdExpandMore size={22} />
</div>
</div>
{/* Expanded detail panel */}
{isExpanded && <RiderDetailPanel rider={rider} metrics={metrics} />}
</div>
);
}
// ─────────────────────────────────────────────────────────────
// 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 (
<div className="profitability-dashboard" role="region" aria-label="Profitability overview">
{/* ── Header ── */}
<header className="profitability-header">
<div className="profitability-header-left">
<div className="profitability-header-icon" aria-hidden="true">
<MdBarChart size={18} />
</div>
<div>
<h2 className="profitability-header-title">Profitability Overview</h2>
<p className="profitability-header-subtitle">
<span>
{enriched.length} rider{enriched.length !== 1 ? 's' : ''}
</span>
<span className="profitability-header-dot" />
<span style={{ color: 'var(--profit-green)' }}>{profitCount} profitable</span>
<span className="profitability-header-dot" />
<span style={{ color: 'var(--loss-red)' }}>{lossCount} at loss</span>
</p>
</div>
</div>
<div className="profitability-kpi-group">
{/* Daily profit / loss */}
<div className={`profitability-kpi-chip ${dailyIsProfit ? 'profitability-kpi-chip--profit' : 'profitability-kpi-chip--loss'}`}>
<div className="profitability-kpi-chip-icon" aria-hidden="true">
{dailyIsProfit ? <MdTrendingUp /> : <MdTrendingDown />}
</div>
<div className="profitability-kpi-chip-content">
<span className="profitability-kpi-chip-label">Daily {dailyIsProfit ? 'profit' : 'loss'}</span>
<span className="profitability-kpi-chip-amount">{rupees(Math.abs(totalDailyProfit))}</span>
</div>
</div>
{/* Slot profit / loss */}
<div className={`profitability-kpi-chip ${slotIsProfit ? 'profitability-kpi-chip--profit' : 'profitability-kpi-chip--loss'}`}>
<div className="profitability-kpi-chip-icon" aria-hidden="true">
{slotIsProfit ? <MdTrendingUp /> : <MdTrendingDown />}
</div>
<div className="profitability-kpi-chip-content">
<span className="profitability-kpi-chip-label">Slot {slotIsProfit ? 'profit' : 'loss'}</span>
<span className="profitability-kpi-chip-amount">{rupees(Math.abs(slotNet))}</span>
</div>
</div>
</div>
</header>
{/* ── Summary statistics row ── */}
<div className="profitability-summary-row" role="group" aria-label="Slot summary">
<div className="profitability-summary-card profitability-summary-card--primary">
<span className="profitability-summary-label">Riders Active</span>
<span className="profitability-summary-value">{enriched.length}</span>
<span className="profitability-summary-detail">
{profitCount} in profit · {lossCount} at loss
</span>
</div>
<div className="profitability-summary-card profitability-summary-card--primary">
<span className="profitability-summary-label">Slot Revenue</span>
<span className="profitability-summary-value">{rupees(slotRevenue)}</span>
<span className="profitability-summary-detail">From {enriched.reduce((s, r) => s + (r.orders?.length ?? 0), 0)} orders</span>
</div>
<div className="profitability-summary-card profitability-summary-card--loss">
<span className="profitability-summary-label">Slot Cost</span>
<span className="profitability-summary-value profitability-summary-value--loss">{rupees(slotCost)}</span>
<span className="profitability-summary-detail">Fixed + variable</span>
</div>
<div className={`profitability-summary-card ${slotIsProfit ? 'card-profit' : 'card-loss'}`}>
<span className="profitability-summary-label">Slot Net</span>
<span className={`profitability-summary-value ${slotIsProfit ? 'net-positive' : 'net-negative'}`}>
{slotIsProfit ? '+' : ''}
{rupees(slotNet)}
</span>
<span
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>
</div>
</div>
{/* ── Controls (Search) (removed) ── */}
{/* ── Rider feed ── */}
<div className="rider-profitability-feed" role="list" aria-label="Rider cards">
<div className="rider-feed-heading" aria-hidden="true">
Riders List
</div>
{sortedAndFiltered.length === 0 ? (
<div className="profitability-empty-state" role="status">
<MdPeopleAlt size={32} aria-hidden="true" />
<p className="profitability-empty-message">No riders matching the filters.</p>
</div>
) : (
sortedAndFiltered.map((rider) => (
<div key={rider.id} role="listitem">
<RiderProfitabilityCard
rider={rider}
metrics={rider._m}
isExpanded={!!expanded[rider.id]}
isFocused={focusedRider?.id === rider.id}
onToggle={toggleRider}
onFocus={handleRiderFocus}
/>
</div>
))
)}
</div>
</div>
);
}
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
};