update for the the active section page and the profitability page
This commit is contained in:
628
src/pages/nearle/dispatch/ProfitabilitySection.js
Normal file
628
src/pages/nearle/dispatch/ProfitabilitySection.js
Normal file
@@ -0,0 +1,628 @@
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
MdTrendingUp,
|
||||
MdTrendingDown,
|
||||
MdExpandMore,
|
||||
MdReceipt,
|
||||
MdPayments,
|
||||
MdRoute,
|
||||
MdLocationOn,
|
||||
MdBarChart,
|
||||
MdPeopleAlt,
|
||||
MdSearch
|
||||
} 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 ?? idx}>
|
||||
<td>
|
||||
<CustomerCell order={order} />
|
||||
</td>
|
||||
<td>
|
||||
<OrderStatusPill status={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 [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterTab, setFilterTab] = useState('all'); // 'all', 'profitable', 'loss'
|
||||
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 based on search query and filter tabs
|
||||
const filtered = useMemo(() => {
|
||||
return enriched.filter((r) => {
|
||||
const name = r.riderName ?? r.username ?? `Rider #${r.id}`;
|
||||
const matchesSearch = name.toLowerCase().includes(searchQuery.toLowerCase()) || String(r.id).includes(searchQuery);
|
||||
|
||||
const isProfitable = r._m.net >= 0;
|
||||
let matchesTab = true;
|
||||
if (filterTab === 'profitable') {
|
||||
matchesTab = isProfitable;
|
||||
} else if (filterTab === 'loss') {
|
||||
matchesTab = !isProfitable;
|
||||
}
|
||||
|
||||
return matchesSearch && matchesTab;
|
||||
});
|
||||
}, [enriched, searchQuery, filterTab]);
|
||||
|
||||
// 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={24} />
|
||||
</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 profitability-summary-card--profit">
|
||||
<span className="profitability-summary-label">Slot Net</span>
|
||||
<span
|
||||
className={`profitability-summary-value ${
|
||||
slotIsProfit ? 'profitability-summary-value--profit' : 'profitability-summary-value--loss'
|
||||
}`}
|
||||
>
|
||||
{slotIsProfit ? '+' : ''}
|
||||
{rupees(slotNet)}
|
||||
</span>
|
||||
<span className="profitability-summary-detail">{slotRevenue > 0 ? ((slotNet / slotRevenue) * 100).toFixed(0) : 0}% margin</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Controls (Search, Filter, Sort) ── */}
|
||||
<div className="profitability-controls-bar">
|
||||
<div className="profitability-search-wrapper">
|
||||
<MdSearch className="profitability-search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
className="profitability-search-input"
|
||||
placeholder="Search rider by name or ID..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="profitability-filter-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`profitability-tab-btn ${filterTab === 'all' ? 'tab-active' : ''}`}
|
||||
onClick={() => setFilterTab('all')}
|
||||
>
|
||||
All <span className="profitability-tab-count">{enriched.length}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`profitability-tab-btn ${filterTab === 'profitable' ? 'tab-active' : ''}`}
|
||||
onClick={() => setFilterTab('profitable')}
|
||||
>
|
||||
Profitable <span className="profitability-tab-count">{profitCount}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`profitability-tab-btn ${filterTab === 'loss' ? 'tab-active' : ''}`}
|
||||
onClick={() => setFilterTab('loss')}
|
||||
>
|
||||
At Loss <span className="profitability-tab-count">{lossCount}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="profitability-sort-wrapper">
|
||||
<span className="profitability-sort-label">Sort:</span>
|
||||
<select
|
||||
className="profitability-sort-select"
|
||||
value={sortMode}
|
||||
onChange={(e) => setSortMode(e.target.value)}
|
||||
aria-label="Sort riders"
|
||||
>
|
||||
<option value="profit-asc">Lowest Profit First</option>
|
||||
<option value="profit-desc">Highest Profit First</option>
|
||||
<option value="name-asc">Rider Name (A-Z)</option>
|
||||
<option value="orders-desc">Orders Count (High-Low)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 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
|
||||
};
|
||||
Reference in New Issue
Block a user