updates on the api integration and the design changes on the whole website
This commit is contained in:
187
src/pages/nearle/reports/profitability.js
Normal file
187
src/pages/nearle/reports/profitability.js
Normal file
@@ -0,0 +1,187 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
|
||||
// material-ui
|
||||
import { Box, Paper, TextField, Typography } from '@mui/material';
|
||||
import { MdCalendarMonth } from 'react-icons/md';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { fetchDeliveries } from 'pages/api/api';
|
||||
import Loader from 'components/Loader';
|
||||
import PageHeader from 'components/nearle_components/PageHeader';
|
||||
import ProfitabilitySection, {
|
||||
BASE_REVENUE,
|
||||
BASE_KM_LIMIT,
|
||||
EXTRA_RATE_KM,
|
||||
VARIABLE_RATE_KM,
|
||||
FIXED_COST_PER_SLOT,
|
||||
BATCHES_DEFAULT
|
||||
} from '../dispatch/ProfitabilitySection';
|
||||
import { SKIPPED_STATUSES } from '../dispatch/dispatchShared';
|
||||
|
||||
// ============================================================================
|
||||
// Standalone Profitability report — reference project (nearle_console_express)
|
||||
// has this as its own page (reports/profitability.js); here it only existed
|
||||
// embedded inside Dispatch.js's live map. Rather than rebuild the UI (the
|
||||
// reference re-implements the profit math inline in raw MUI), this reuses
|
||||
// the already-built, already-tested ProfitabilitySection.js component as-is
|
||||
// and just supplies it with independently-fetched data — mirrors how the
|
||||
// reference page ALSO re-fetches and re-groups independently rather than
|
||||
// reusing its own Dispatch page's pipeline (which is entangled with
|
||||
// Leaflet/GPS/zone-bucketing state not needed for a report).
|
||||
//
|
||||
// GET /admin/bookings has no server-side date/zone/status filter params (see
|
||||
// src/pages/api/CLAUDE.md) — bulk-fetched and grouped entirely client-side,
|
||||
// same pattern as reports/ordersDetails.js and deliveries.js.
|
||||
// ============================================================================
|
||||
|
||||
const DT = {
|
||||
radiusCard: 14,
|
||||
shadowSoft: '0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.05)',
|
||||
textSecondary: '#64748b',
|
||||
borderSubtle: '#e2e8f0',
|
||||
surface: '#ffffff'
|
||||
};
|
||||
const BRAND = '#C01227';
|
||||
|
||||
// Profit formulas + batch windows imported from ProfitabilitySection.js
|
||||
// (the single source of truth) rather than duplicated here — used to total
|
||||
// across all riders for the daily KPI chip; per-rider numbers are computed
|
||||
// by ProfitabilitySection itself from the same `riders` data, so this must
|
||||
// match exactly or the chip and the table below it would disagree.
|
||||
const orderRevenue = (km) => (km <= BASE_KM_LIMIT ? BASE_REVENUE : BASE_REVENUE + (km - BASE_KM_LIMIT) * EXTRA_RATE_KM);
|
||||
|
||||
const getOrderBatch = (o) => {
|
||||
const t = o.assigntime || o.deliverydate;
|
||||
if (!t || /^\d{4}-\d{2}-\d{2}$/.test(String(t).trim())) return null;
|
||||
const d = dayjs(t);
|
||||
if (!d.isValid()) return null;
|
||||
const hour = d.hour() + d.minute() / 60;
|
||||
const batch = BATCHES_DEFAULT.find((b) => hour >= b.startHour && hour < b.endHour);
|
||||
return batch?.id || null;
|
||||
};
|
||||
|
||||
const Profitability = () => {
|
||||
const [selectedDate, setSelectedDate] = useState(dayjs().format('YYYY-MM-DD'));
|
||||
|
||||
const {
|
||||
data: deliveriesData,
|
||||
isLoading,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ['fetchdeliveries-profitability', 0, 0, 'all', selectedDate, selectedDate, 500, '', 0, 0, 0],
|
||||
queryFn: fetchDeliveries,
|
||||
getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined
|
||||
});
|
||||
|
||||
const allRows = useMemo(() => (deliveriesData?.pages || []).flatMap((page) => page.rows || []), [deliveriesData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasNextPage && !isFetchingNextPage) fetchNextPage();
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage, allRows.length]);
|
||||
|
||||
// Group into { id, riderName, orders } — flat, no zone/suburb bucketing
|
||||
// (that's a Dispatch-map-specific concern, not needed for a report).
|
||||
// riderkms is what ProfitabilitySection's revenue/cost math actually reads
|
||||
// (see orderRevenue/calcRiderMetrics there) but GET /admin/bookings has no
|
||||
// road-distance field — mapped from the same haversine straight-line `kms`
|
||||
// every other page in this app already uses as the distance proxy.
|
||||
const riders = useMemo(() => {
|
||||
const byRider = new Map();
|
||||
allRows.forEach((row) => {
|
||||
if (!row.userid) return;
|
||||
const key = String(row.userid);
|
||||
if (!byRider.has(key)) {
|
||||
byRider.set(key, { id: key, riderName: row.ridername || `Rider #${key}`, orders: [] });
|
||||
}
|
||||
byRider.get(key).orders.push({ ...row, riderkms: row.kms });
|
||||
});
|
||||
return Array.from(byRider.values());
|
||||
}, [allRows]);
|
||||
|
||||
const totalDailyProfit = useMemo(() => {
|
||||
let profit = 0;
|
||||
riders.forEach((rider) => {
|
||||
let revenue = 0;
|
||||
let kms = 0;
|
||||
const batchesWorked = new Set();
|
||||
rider.orders.forEach((o) => {
|
||||
const status = String(o.orderstatus || '').toLowerCase();
|
||||
if (SKIPPED_STATUSES.has(status)) return;
|
||||
const dateStr = o.assigntime
|
||||
? dayjs(o.assigntime).format('YYYY-MM-DD')
|
||||
: o.deliverydate
|
||||
? dayjs(o.deliverydate).format('YYYY-MM-DD')
|
||||
: null;
|
||||
if (dateStr !== selectedDate) return;
|
||||
const km = parseFloat(o.riderkms || 0);
|
||||
revenue += orderRevenue(km);
|
||||
kms += km;
|
||||
const batch = getOrderBatch(o);
|
||||
if (batch) batchesWorked.add(batch);
|
||||
});
|
||||
const variableCost = kms * VARIABLE_RATE_KM;
|
||||
const fixedCost = Math.min(batchesWorked.size, 3) * FIXED_COST_PER_SLOT;
|
||||
profit += revenue - variableCost - fixedCost;
|
||||
});
|
||||
return profit;
|
||||
}, [riders, selectedDate]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading && <Loader />}
|
||||
|
||||
<PageHeader
|
||||
title="Profitability"
|
||||
subtitle={`Live · ${dayjs(selectedDate).format('DD MMM YYYY')}`}
|
||||
live
|
||||
action={
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
px: 1.5,
|
||||
py: 0.75,
|
||||
borderRadius: DT.radiusCard + 'px',
|
||||
border: '1px solid',
|
||||
borderColor: DT.borderSubtle,
|
||||
boxShadow: DT.shadowSoft,
|
||||
bgcolor: DT.surface
|
||||
}}
|
||||
>
|
||||
<MdCalendarMonth size={16} color={BRAND} />
|
||||
<TextField
|
||||
type="date"
|
||||
size="small"
|
||||
variant="standard"
|
||||
value={selectedDate}
|
||||
onChange={(e) => e.target.value && setSelectedDate(e.target.value)}
|
||||
InputProps={{ disableUnderline: true }}
|
||||
sx={{ '& input': { fontWeight: 700, color: DT.textSecondary, p: 0 } }}
|
||||
/>
|
||||
</Paper>
|
||||
}
|
||||
/>
|
||||
|
||||
<Box sx={{ mt: { xs: 1.5, md: 2 } }}>
|
||||
{!isLoading && riders.length === 0 ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 6, textAlign: 'center', borderRadius: DT.radiusCard + 'px', border: '1px solid', borderColor: DT.borderSubtle }}
|
||||
>
|
||||
<Typography sx={{ fontWeight: 700, color: DT.textSecondary }}>No deliveries for {dayjs(selectedDate).format('DD MMM YYYY')}</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
<ProfitabilitySection riders={riders} totalDailyProfit={totalDailyProfit} selectedDate={selectedDate} />
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Profitability;
|
||||
Reference in New Issue
Block a user