import React, { useEffect, useMemo, useState } from 'react'; import { useInfiniteQuery } from '@tanstack/react-query'; import { MdCalendarMonth } from 'react-icons/md'; import { Card } from '@astryxdesign/core/Card'; import { HStack } from '@astryxdesign/core/HStack'; import { VStack } from '@astryxdesign/core/VStack'; import { EmptyState } from '@astryxdesign/core/EmptyState'; import { DateInput } from '@astryxdesign/core/DateInput'; import dayjs from 'dayjs'; import { parseDoormileTimestamp } from 'utils/doormileTimestamp'; 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 BRAND = '#000000'; // 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); // Buckets on `orderdate` (the booking's createdat) and parses with // parseDoormileTimestamp — must match ProfitabilitySection.getRowBatch, // Dispatch.js's BATCH_TIME_FIELD and deliveries.js's BATCH_TIME_KEYS. // // It used to read `assigntime`, which api.js maps to the backend's // last-modified column, so it was re-stamped by any status change and a // rider's slot count drifted through the day; and it parsed with a bare // dayjs(), which mis-handles the false trailing Z some Doormile timestamps // carry. Slot count feeds the FIXED cost, so both bugs moved money. const getOrderBatch = (o) => { const t = o.orderdate; if (!t || /^\d{4}-\d{2}-\d{2}$/.test(String(t).trim())) return null; const d = parseDoormileTimestamp(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; // parseDoormileTimestamp (not bare dayjs()) avoids the false-trailing-Z // parse bug (utils/doormileTimestamp.js) — see ProfitabilitySection.js. const dateStr = o.orderdate ? parseDoormileTimestamp(o.orderdate).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 && } v && setSelectedDate(v)} /> } /> {!isLoading && riders.length === 0 ? ( ) : ( )} ); }; export default Profitability;