initial commit

This commit is contained in:
2026-08-26 15:01:15 +05:30
parent aa42d9ee81
commit cf4f4ecd9c
87 changed files with 12814 additions and 2328 deletions

View File

@@ -9,7 +9,6 @@ import {
Modal, PageHeader, SearchInput, Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
Stack, StatusBadge, Surface, Switch, Tabs,
} from '@/components/ds';
import { ListToolbar } from '@/components/doormile/ListToolbar';
import {
useCreateTenantLocation, usePricing, useTenantCustomers, useTenantLocations,
useTenants, useUpdateTenant, useUpdateTenantLocation,
@@ -449,20 +448,24 @@ export default function Tenants() {
</Grid>
<Stack space="sm">
<div className="flex items-center justify-between gap-4 w-full">
<Tabs
tabs={STATUS_TABS.map((entry) => ({ ...entry, count: stats[entry.value] }))}
value={tab}
onChange={setTab}
className="shrink-0"
/>
{/* Wraps, and the tab strip scrolls in its own track — see Orders.jsx
for why two `shrink-0` halves scrolled the whole page on a phone. */}
<div className="flex flex-wrap items-center justify-between gap-3 w-full">
<div className="-mx-1 max-w-full min-w-0 overflow-x-auto px-1 py-0.5">
<Tabs
tabs={STATUS_TABS.map((entry) => ({ ...entry, count: stats[entry.value] }))}
value={tab}
onChange={setTab}
className="shrink-0"
/>
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="flex min-w-0 flex-1 items-center justify-end gap-2 sm:flex-none">
<SearchInput
value={search}
onChange={setSearch}
placeholder="Search by client name, email or phone"
className="w-72"
className="w-full sm:w-72"
/>
</div>
</div>

View File

@@ -12,7 +12,7 @@ import {
useUpdateDeliveryStatus,
} from '@/lib/doormileHooks';
import { BATCHES, batchRangeLabel, getRowBatchId } from '@/lib/batchBucket';
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
import { formatDoormileTimestamp, parseDoormileTimestamp } from '@/lib/doormileTimestamp';
import { currency, exportRows, km as formatKm, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
/**
@@ -111,7 +111,13 @@ export default function Deliveries() {
const cancelDelivery = useCancelDelivery();
const notifyMiler = useNotifyMiler();
const allRows = data?.rows || [];
const allRows = useMemo(() => {
return [...(data?.rows || [])].sort((a, b) => {
const ta = parseDoormileTimestamp(a.orderdate || a.assigntime).valueOf() || 0;
const tb = parseDoormileTimestamp(b.orderdate || b.assigntime).valueOf() || 0;
return tb - ta;
});
}, [data?.rows]);
/* Batch, client and rider first, then status, then search — the counts under
the tabs have to describe the slice the operator has chosen, not the whole
@@ -373,7 +379,12 @@ export default function Deliveries() {
/>
<Stack space="sm">
<Tabs tabs={tabs} value={tab} onChange={setTab} />
{/* Eight status tabs are far wider than a phone. Scrolled in their own
track so the strip moves instead of the whole document — see
Orders.jsx for the same fix. */}
<div className="-mx-1 max-w-full overflow-x-auto px-1 py-0.5">
<Tabs tabs={tabs} value={tab} onChange={setTab} className="shrink-0" />
</div>
<ListToolbar
search={search}
@@ -632,10 +643,10 @@ function OrderDetailDrawer({ row, onClose }) {
<Surface variant="subtle" padding="sm" radius="lg">
<dl className="grid grid-cols-2 gap-3">
{[
['Status', row?.orderstatus],
['Rider', row?.ridername],
['Customer', row?.deliverycustomer],
['Contact', row?.deliverycontactno],
['Status', row?.orderstatus ? <StatusBadge status={row.orderstatus} size="sm" /> : '—'],
['Rider', orDash(row?.ridername)],
['Customer', orDash(row?.deliverycustomer)],
['Contact', orDash(row?.deliverycontactno)],
['Charge', currency(row?.deliverycharges)],
['Distance', formatKm(row?.kms)],
['Placed', formatDoormileTimestamp(row?.orderdate)],
@@ -643,7 +654,7 @@ function OrderDetailDrawer({ row, onClose }) {
].map(([label, value]) => (
<div key={label}>
<dt className="text-caption uppercase tracking-wide text-ink-3">{label}</dt>
<dd className="text-body-sm text-ink-1">{orDash(value)}</dd>
<dd className="text-body-sm text-ink-1">{value}</dd>
</div>
))}
</dl>

View File

@@ -6,78 +6,17 @@ import {
MdRestaurant,
MdMyLocation,
MdAccessTime,
MdInventory2
MdInventory2,
MdStraighten,
} from 'react-icons/md';
import { List, ListItem } from '@astryxdesign/core/List';
import { VStack } from '@astryxdesign/core/VStack';
import { HStack } from '@astryxdesign/core/HStack';
import { Center } from '@astryxdesign/core/Center';
import { Text } from '@astryxdesign/core/Text';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Tooltip } from '@astryxdesign/core/Tooltip';
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
import LoaderWithImage from 'components/nearle_components/LoaderWithImage';
import StatusBadge from 'components/nearle_components/StatusBadge';
import { AccentAvatar } from 'themes/dt/primitives';
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
import { getActiveOrder } from './dispatchShared';
import { OpenToast } from 'components/third-party/OpenToast';
// ==============================|| ACTIVE DELIVERIES (sidebar) ||============================== //
// Rows, not cards. Astryx's design rule is that dense operator data reads as
// edge-to-edge rows — the old `.adcard` bordered cards cost ~16px of chrome per
// delivery, which is a lot of scrolling once a slot has 20+ riders out.
//
// Status comes from <StatusBadge> (themes/dt/status.js), which is now the only
// status-colour table in this folder — dispatchShared's rival copy has been
// deleted and its getStatusStyle reduced to an adapter over the same source.
// Small labelled metric — icon + value, used for the distance/ETA pair.
// The old markup carried a native `title` for the label ("Distance to drop");
// Astryx's BaseProps deliberately omits `title`, so the affordance moves to the
// real <Tooltip> rather than silently disappearing onto a dropped attribute.
const Metric = ({ icon, children, label }) => (
<Tooltip content={label}>
<HStack gap={0.5} vAlign="center">
<Center isInline width={14} height={14} style={{ color: 'var(--color-icon-secondary)' }}>
{icon}
</Center>
<Text size="sm" type="supporting" hasTabularNumbers>
{children}
</Text>
</HStack>
</Tooltip>
);
Metric.propTypes = {
icon: PropTypes.node,
children: PropTypes.node,
label: PropTypes.string
};
// One line of secondary context (rider / drop area / pickup) under the customer.
// `maxLines={1}` turns on Text's own truncate tooltip, which replaces the
// `title` the old spans used to show the full value when clipped.
const ContextLine = ({ icon, children }) => (
<HStack gap={1} vAlign="center">
<Center isInline width={13} height={13} style={{ flexShrink: 0, color: 'var(--color-icon-secondary)' }}>
{icon}
</Center>
<Text size="sm" type="supporting" maxLines={1}>
{children}
</Text>
</HStack>
);
ContextLine.propTypes = {
icon: PropTypes.node,
children: PropTypes.node
};
import { OpenToast } from '@/components/third-party/OpenToast';
const ActiveSection = ({
visibleRiders,
riders,
visibleRiders = [],
riders = [],
focusedStop,
handleRiderFocus,
setFocusedStop,
@@ -116,17 +55,14 @@ const ActiveSection = ({
Object.entries(currentMap).forEach(([oid, cur]) => {
const old = prev[oid];
if (!old) {
// Brand-new order that is already active → rider has taken a new order
if (cur.status === 'active') {
OpenToast(`🛵 ${cur.riderName} is now delivering to ${cur.customer}`, 'success', 3000);
}
return;
}
// Rider transitioned into active → started heading to customer
if (old.status !== 'active' && cur.status === 'active') {
OpenToast(`🛵 ${cur.riderName} is now delivering to ${cur.customer}`, 'success', 3000);
}
// Rider delivered → reached customer location
if (old.status === 'active' && cur.status === 'delivered') {
OpenToast(`${cur.riderName} has reached ${cur.customer}'s location`, 'info', 3000);
}
@@ -138,108 +74,179 @@ const ActiveSection = ({
if (isLoading) {
return (
<VStack gap={2} padding={4} hAlign="center" vAlign="center" minHeight={200}>
<LoaderWithImage size={44} imgSize={24} />
<Text type="supporting">Loading active deliveries…</Text>
</VStack>
<div className="flex flex-col items-center justify-center p-8 min-h-[220px] text-center space-y-3">
<div className="w-8 h-8 rounded-full border-3 border-slate-200 border-t-slate-800 animate-spin" />
<span className="text-xs text-slate-500 font-medium">Loading active deliveries…</span>
</div>
);
}
if (activeDeliveries.length === 0) {
return (
<EmptyState
title="No active deliveries"
description="No deliveries are currently in progress for this slot."
icon={<MdInventory2 size={28} />}
/>
<div className="flex flex-col items-center justify-center p-8 text-center min-h-[240px] space-y-3 bg-white rounded-xl border border-slate-200/80 shadow-2xs my-2">
<div className="w-12 h-12 rounded-2xl bg-slate-100 text-slate-400 flex items-center justify-center">
<MdInventory2 className="w-6 h-6" />
</div>
<div>
<div className="text-xs font-bold text-slate-800">No active deliveries</div>
<div className="text-[11px] text-slate-500 mt-0.5">No deliveries are currently in progress for this slot.</div>
</div>
</div>
);
}
const renderActiveDelivery = (o) => {
const rid = o.rider_id || o.userid;
const rider = riders.find((r) => String(r.id) === String(rid));
const color = getRiderColor(rid);
const lat = parseFloat(o.droplat || o.deliverylat);
const lon = parseFloat(o.droplon || o.deliverylong);
const canFocus = Number.isFinite(lat) && Number.isFinite(lon);
const estMeters = calculateEstMeters(rid, o);
const customer = o.deliverycustomer || o.customername || `Order #${o.orderid}`;
const dropArea = o.deliverysuburb || o.deliveryaddress || o.zone_name || '';
const riderName = o.rider_name || o.ridername || 'Unassigned';
const isSelected = canFocus && focusedStop && String(focusedStop.orderid) === String(o.orderid);
const initials =
riderName
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0])
.join('')
.toUpperCase() || '•';
const deliveredAt = o.deliverytime
? parseDoormileTimestamp(o.deliverytime).isValid()
? parseDoormileTimestamp(o.deliverytime).format('HH:mm:ss')
: String(o.deliverytime)
: null;
// 20 km/h is the same city-average assumption the old card used.
const etaMin = estMeters !== null ? (estMeters / 1000 / 20) * 60 : null;
return (
<ListItem
key={o.orderid}
label={customer}
isSelected={!!isSelected}
onClick={() => {
if (rider) handleRiderFocus(rider);
if (canFocus) setFocusedStop({ orderid: o.orderid, lat, lon });
}}
startContent={
// Rider colour is an arbitrary per-rider hex assigned by the map's
// palette, which no semantic token can express — this is the same
// caller-supplied-accent escape hatch StatCard uses.
<AccentAvatar color={color} selected size={32}>
<Text size="sm" weight="bold" style={{ color: 'inherit' }}>
{initials}
</Text>
</AccentAvatar>
}
description={
<VStack gap={0.5} padding={0}>
<ContextLine icon={<MdTwoWheeler size={13} />}>{riderName}</ContextLine>
{dropArea && <ContextLine icon={<MdLocationOn size={13} />}>{dropArea}</ContextLine>}
{o.pickupcustomer && <ContextLine icon={<MdRestaurant size={13} />}>{o.pickupcustomer}</ContextLine>}
</VStack>
}
endContent={
<VStack gap={1} padding={0} hAlign="end">
<StatusBadge status={o.orderstatus} />
{deliveredAt && (
<Text size="sm" type="supporting" hasTabularNumbers>
{deliveredAt}
</Text>
)}
{estMeters !== null && (
<HStack gap={2} vAlign="center">
<Metric icon={<MdMyLocation size={14} />} label="Distance to drop">
{formatMeters(estMeters)}
</Metric>
<Metric icon={<MdAccessTime size={14} />} label="Estimated time to drop">
{etaMin < 1 ? '< 1 min' : `${Math.ceil(etaMin)} min`}
</Metric>
</HStack>
)}
</VStack>
}
/>
);
};
return (
<List density="balanced" hasDividers>
{activeDeliveries.map(renderActiveDelivery)}
</List>
<div className="space-y-3.5 w-full pb-4">
{activeDeliveries.map((o) => {
const rid = o.rider_id || o.userid;
const rider = riders.find((r) => String(r.id) === String(rid));
const color = getRiderColor(rid) || '#0284c7';
const lat = parseFloat(o.droplat || o.deliverylat);
const lon = parseFloat(o.droplon || o.deliverylong);
const canFocus = Number.isFinite(lat) && Number.isFinite(lon);
const estMeters = calculateEstMeters(rid, o);
const customer = o.deliverycustomer || o.customername || `Order #${o.orderid}`;
const dropArea = o.deliverysuburb || o.deliveryaddress || o.zone_name || '';
const riderName = o.rider_name || o.ridername || 'Unassigned';
const isSelected = canFocus && focusedStop && String(focusedStop.orderid) === String(o.orderid);
const initials =
riderName
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0])
.join('')
.toUpperCase() || '•';
const deliveredAt = o.deliverytime
? parseDoormileTimestamp(o.deliverytime).isValid()
? parseDoormileTimestamp(o.deliverytime).format('hh:mm A')
: String(o.deliverytime)
: null;
const etaMin = estMeters !== null ? (estMeters / 1000 / 20) * 60 : null;
const totalDistanceKm = Number(o.actualkms || o.kms || 0);
return (
<div
key={o.orderid}
onClick={() => {
if (rider) handleRiderFocus(rider);
if (canFocus) setFocusedStop({ orderid: o.orderid, lat, lon });
}}
className={`group bg-white rounded-xl border p-4 transition-all duration-150 cursor-pointer shadow-2xs select-none relative overflow-hidden ${
isSelected
? 'border-blue-600 ring-2 ring-blue-500/20 bg-blue-50/20 shadow-sm'
: 'border-slate-200/90 hover:border-slate-300 hover:shadow-xs'
}`}
>
{/* Top Accent Strip */}
<div
className="absolute top-0 left-0 right-0 h-1"
style={{ backgroundColor: color }}
/>
{/* Header: Rider Avatar + Customer Name + Status Badge */}
<div className="flex items-start justify-between gap-3 pt-1">
<div className="flex items-center gap-2.5 min-w-0 flex-1">
<div
className="w-8 h-8 rounded-lg shrink-0 flex items-center justify-center font-black text-white text-xs shadow-xs"
style={{ backgroundColor: color }}
>
{initials}
</div>
<div className="min-w-0 flex-1">
<h4 className="font-bold text-slate-900 text-xs tracking-tight truncate" title={customer}>
{customer}
</h4>
<div className="text-[10px] font-mono text-slate-400 mt-0.5">
Order #{o.orderid} {o.trip_number ? `· Trip ${o.trip_number}` : ''}
</div>
</div>
</div>
<span className="inline-flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider px-2.5 py-0.5 rounded-full bg-emerald-50 text-emerald-700 border border-emerald-200 shrink-0">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
{o.orderstatus || 'Active'}
</span>
</div>
{/* Middle: Rider Info & Locations */}
<div className="mt-3 pt-2.5 border-t border-slate-100 space-y-1.5 text-xs">
<div className="flex items-center gap-2 text-slate-700 font-semibold truncate">
<div className="w-5 h-5 rounded-md bg-slate-100 text-slate-600 flex items-center justify-center shrink-0">
<MdTwoWheeler className="w-3.5 h-3.5" />
</div>
<span className="truncate">{riderName}</span>
</div>
{dropArea && (
<div className="flex items-center gap-2 text-slate-500 text-[11px] truncate" title={dropArea}>
<div className="w-5 h-5 rounded-md bg-slate-50 text-slate-400 flex items-center justify-center shrink-0">
<MdLocationOn className="w-3.5 h-3.5 text-orange-500" />
</div>
<span className="truncate">{dropArea}</span>
</div>
)}
{o.pickupcustomer && (
<div className="flex items-center gap-2 text-slate-500 text-[11px] truncate" title={o.pickupcustomer}>
<div className="w-5 h-5 rounded-md bg-slate-50 text-slate-400 flex items-center justify-center shrink-0">
<MdRestaurant className="w-3.5 h-3.5 text-amber-500" />
</div>
<span className="truncate">{o.pickupcustomer}</span>
</div>
)}
</div>
{/* Bottom Metrics Bar */}
<div className="mt-3 pt-2.5 border-t border-slate-100 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
{totalDistanceKm > 0 && (
<span className="inline-flex items-center gap-1 text-[11px] font-mono font-medium text-slate-600 bg-slate-50 px-2 py-0.5 rounded border border-slate-200/60">
<MdStraighten className="w-3 h-3 text-slate-400" />
{totalDistanceKm.toFixed(1)} km
</span>
)}
{deliveredAt && (
<span className="inline-flex items-center gap-1 text-[11px] font-mono text-slate-500">
<MdAccessTime className="w-3 h-3 text-slate-400" />
{deliveredAt}
</span>
)}
</div>
{estMeters !== null && (
<div className="inline-flex items-center gap-1.5 text-[11px] font-mono font-bold text-sky-900 bg-sky-50 px-2.5 py-0.5 rounded-full border border-sky-200/80">
<MdMyLocation className="w-3 h-3 text-sky-600 animate-spin-slow" />
<span>{formatMeters(estMeters)}</span>
{etaMin !== null && (
<>
<span className="text-sky-300">·</span>
<span className="text-sky-700">{etaMin < 1 ? '< 1m' : `${Math.ceil(etaMin)}m`}</span>
</>
)}
</div>
)}
</div>
</div>
);
})}
</div>
);
};
ActiveSection.propTypes = {
visibleRiders: PropTypes.array,
riders: PropTypes.array,
focusedStop: PropTypes.object,
handleRiderFocus: PropTypes.func,
setFocusedStop: PropTypes.func,
calculateEstMeters: PropTypes.func,
getRiderColor: PropTypes.func,
formatMeters: PropTypes.func,
isLoading: PropTypes.bool
};
export default ActiveSection;

View File

@@ -41,8 +41,6 @@
.dispatch-container * {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* Header */

View File

@@ -56,7 +56,8 @@ import {
} from 'react-icons/md';
import ProfitabilitySection from './ProfitabilitySection';
import ActiveSection from './ActiveSection';
import { fetchDeliveries, fetchAppLocations, getRiderPeriodicLogs, fetchRidersLogs, fetchBatchEfficiency } from '../../api/api';
import { fetchDeliveries, fetchAppLocations, getRiderPeriodicLogs, fetchRidersLogs, fetchBatchEfficiency, fetchRidersList, assignMilerToBooking } from '../../api/api';
import { OpenToast } from 'components/third-party/OpenToast';
import { BATCHES as SHARED_BATCHES } from 'utils/batchBucket';
import { getConsignmentLogs } from 'pages/api/doormileApi';
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
@@ -120,6 +121,23 @@ const extractArea = (addr) => {
};
const hasValidDrop = (o) => Number.isFinite(toNum(o.droplat || o.deliverylat)) && Number.isFinite(toNum(o.droplon || o.deliverylong));
/**
* A stable React key for one drop marker.
*
* The board's own rows always carry `orderid` (fetchDeliveries sets it), but the
* optimiser's preview rows are shaped by the solver and may not — which keyed
* every marker on that map `undefined-flag`. The index is the last resort so a
* key is always unique; anything identifying is preferred, so a marker keeps its
* identity when the list is reordered.
*/
const markerKeyFor = (o, index) => {
/* `0` and `''` are placeholders here, not identifiers: Orders.jsx stamps
`deliveryid: 0` onto every delivery it hands the optimiser, and the solver
echoes it back — so a plain `??` chain keys every marker `0`, which is the
same collision under a different name. */
const identifies = (v) => v != null && v !== '' && v !== 0 && v !== '0';
return [o?.orderid, o?.deliveryid, o?.orderheaderid, o?.bookingid].find(identifies) ?? `idx-${index}`;
};
// Try multiple field-name variants — the live delivery API may return pickuplatitude/picklongitude
// or pickuplongitude instead of the shorter pickuplat/pickuplong used in the static data.
const pickupLat = (o) => o.pickuplat || o.pickuplatitude || o.pickup_lat;
@@ -293,7 +311,6 @@ function CaptureMap({ targetRef }) {
return () => {
targetRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [map]);
return null;
}
@@ -309,7 +326,6 @@ function MapAutoResize({ trigger }) {
useEffect(() => {
const timers = [0, 60, 120, 200, 300, 380].map((t) => setTimeout(() => map.invalidateSize({ animate: false }), t));
return () => timers.forEach(clearTimeout);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [trigger, map]);
return null;
}
@@ -857,6 +873,57 @@ const Dispatch = ({
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [riderPositionModal]);
// Standalone change-rider dialog state
const [internalChangeDialogOpen, setInternalChangeDialogOpen] = useState(false);
const [internalSelectedOrder, setInternalSelectedOrder] = useState(null);
const [internalSelectedOldRiderId, setInternalSelectedOldRiderId] = useState(null);
const [internalSelectedNewRider, setInternalSelectedNewRider] = useState(null);
const [isAssigningMiler, setIsAssigningMiler] = useState(false);
const { data: ridersList } = useQuery({
queryKey: ['ridersList'],
queryFn: fetchRidersList,
staleTime: 5 * 60 * 1000
});
const riderOptions = useMemo(
() =>
(ridersList || []).map((r) => {
const id = r.userid ?? r.milerprofileid ?? r.id ?? r.milerid;
const name = r.label || r.displayname || r.authname || `${r.firstname || ''} ${r.lastname || ''}`.trim() || `Rider ${id}`;
return {
value: String(id),
label: name,
rider: { ...r, userid: id, id }
};
}),
[ridersList]
);
const handleTriggerChangeRider = (order, rider) => {
if (typeof onChangeRider === 'function') {
onChangeRider(order, rider);
return;
}
const oldId = rider?.id ?? rider?.rider_id ?? rider?.userid ?? order?.rider_id ?? order?.userid ?? null;
setInternalSelectedOldRiderId(oldId);
setInternalSelectedOrder(order);
setInternalSelectedNewRider(null);
setInternalChangeDialogOpen(true);
};
const confirmInternalChangeRider = async () => {
if (!internalSelectedNewRider || !internalSelectedOrder) return;
const orderId = internalSelectedOrder.orderid ?? internalSelectedOrder.deliveryid ?? internalSelectedOrder.bookingid;
const newRiderId = internalSelectedNewRider.userid ?? internalSelectedNewRider.id;
if (!orderId || !newRiderId) return;
try {
setIsAssigningMiler(true);
await assignMilerToBooking(orderId, { mileruserid: newRiderId });
OpenToast('Rider assigned successfully', 'success', 2500);
setInternalChangeDialogOpen(false);
} catch (err) {
OpenToast(err?.response?.data?.message || err?.message || 'Failed to assign rider', 'error', 3000);
} finally {
setIsAssigningMiler(false);
}
};
// Was hardcoded to 916 (the example tenant in the API spec) — every
// operator's batch-efficiency analytics queried the same tenant regardless
@@ -915,7 +982,6 @@ const Dispatch = ({
const id = setInterval(tick, ANALYSIS_POLL_MS);
return () => clearInterval(id);
// batchEfficiencyMutation is stable for the component lifetime.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [topView, activeBatchKey]);
const [activeRiders, setActiveRiders] = useState(new Set());
const [internalFocusedRider, setInternalFocusedRider] = useState(null);
@@ -997,6 +1063,19 @@ const Dispatch = ({
// forever, silently disagreeing with deliveries.js, which has always used
// the constants.
const BATCHES = BATCHES_DEFAULT;
// Batch menu dropdown state
const [batchMenuOpen, setBatchMenuOpen] = useState(false);
const batchMenuRef = useRef(null);
useEffect(() => {
if (!batchMenuOpen) return;
const onDocClick = (e) => {
if (batchMenuRef.current && !batchMenuRef.current.contains(e.target)) {
setBatchMenuOpen(false);
}
};
document.addEventListener('mousedown', onDocClick);
return () => document.removeEventListener('mousedown', onDocClick);
}, [batchMenuOpen]);
// Close the location dropdown on any click outside its wrapper.
useEffect(() => {
@@ -1339,7 +1418,7 @@ const Dispatch = ({
// leg automatically becomes the one shown (card + route + flag) without a
// manual refresh. Other views don't need second-by-second order churn, so
// they refetch only on the usual triggers (date/slot/hub change, refocus).
refetchInterval: viewMode === 'all' ? 15_000 : false,
refetchInterval: 8_000,
refetchIntervalInBackground: false
});
@@ -1978,7 +2057,6 @@ const Dispatch = ({
const smoothed = kalmanSmoothGps(sorted);
// The unused `_ts` binding IS the mechanism — destructuring it out is
// how the internal timestamp is stripped from each point.
// eslint-disable-next-line no-unused-vars
return smoothed.map(({ _ts, ...p }) => p);
},
enabled: compareOpen && focusedRider != null,
@@ -2168,7 +2246,6 @@ const Dispatch = ({
setSidebarCollapsed(preCompareCollapsedRef.current);
}
prevCompareOpenRef.current = compareOpen;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [compareOpen]);
const fetchRoute = useCallback(async (riderId, tripKey, points) => {
@@ -2949,8 +3026,7 @@ const Dispatch = ({
// marker can resolve its step palette color without an O(N) scan.
const compareDeliveryToStep =
compareOpen && focusedRider ? new Map(riderActualTracks.map((t) => [String(t.deliveryid), t.sequenceStep])) : null;
return ordersToRender.map((o) => {
return ordersToRender.map((o, markerIndex) => {
const rid = o.rider_id;
const active = rid ? activeRiders.has(rid) : true;
let color = getRiderColor(rid);
@@ -3018,7 +3094,13 @@ const Dispatch = ({
return (
<Marker
key={`${o.orderid}-${showNumbers ? 'num' : 'flag'}`}
/* The live board's rows come from fetchDeliveries and always carry an
`orderid`, but the optimiser's preview rows do not — every drop then
keyed as `undefined-flag`, and React warns that duplicate keys may
duplicate or omit children. Fall through the other identifiers the
solver's rows do carry before settling on the render position, so a
marker stays uniquely keyed either way. */
key={`${markerKeyFor(o, markerIndex)}-${showNumbers ? 'num' : 'flag'}`}
position={[parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]}
icon={icon}
zIndexOffset={rid ? 100 : 0}
@@ -3321,7 +3403,7 @@ const Dispatch = ({
)}
</div>
)}
<div id="dispatch-top-tabs" className="dtt-inline">
<div id="dispatch-top-tabs" className="dtt-inline flex items-center gap-2">
<button type="button" className={`dtt-tab ${topView === 'live' ? 'active' : ''}`} onClick={() => setTopView('live')}>
<span className="dtt-icon">
<MdMap />
@@ -3334,6 +3416,89 @@ const Dispatch = ({
</span>
Analysis
</button>
{shouldFetchLive && viewMode !== 'rider-info' && (
<div className="relative inline-flex items-center ml-1" ref={batchMenuRef}>
<button
type="button"
onClick={() => setBatchMenuOpen((o) => !o)}
className="flex items-center gap-2 bg-white hover:bg-slate-50 border border-slate-200/90 rounded-lg px-3 py-1.5 text-xs font-semibold text-slate-800 shadow-2xs transition-all cursor-pointer select-none"
>
<MdAccessTime className="text-slate-400 w-3.5 h-3.5 shrink-0" />
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Batch:</span>
<span className="font-bold text-slate-900 text-xs">
{BATCHES.find((b) => b.id === selectedBatch)?.label || 'All Batches'}
</span>
<span className="text-[11px] font-bold px-1.5 py-0.2 rounded-full bg-slate-100 text-slate-700">
{batchCounts[selectedBatch] ?? 0}
</span>
<MdExpandMore
className={`w-4 h-4 text-slate-400 transition-transform duration-150 ${batchMenuOpen ? 'rotate-180 text-black' : ''}`}
/>
</button>
{batchMenuOpen && (
<div className="absolute top-[calc(100%+6px)] left-0 min-w-[240px] bg-white rounded-xl shadow-xl border border-slate-200 p-1.5 z-[9999] animate-in fade-in zoom-in-95 duration-100">
<div className="px-2.5 py-1.5 text-[10px] font-bold uppercase tracking-wider text-slate-400 border-b border-slate-100 mb-1">
Select Delivery Window
</div>
<div className="space-y-0.5">
{BATCHES.map((b) => {
const isSelected = selectedBatch === b.id;
const count = batchCounts[b.id] ?? 0;
return (
<button
key={b.id}
type="button"
onClick={() => {
setSelectedBatch(b.id);
handleRiderFocus(null);
setFocusedKitchen(null);
setFocusedZone(null);
setBatchMenuOpen(false);
}}
className={`w-full flex items-center justify-between gap-3 px-2.5 py-2 rounded-lg text-left transition-colors cursor-pointer ${
isSelected
? 'bg-slate-900 text-white font-bold'
: 'text-slate-700 hover:bg-slate-100/80 font-medium'
}`}
>
<div className="flex items-center gap-2.5 min-w-0">
<span
className={`w-2 h-2 rounded-full shrink-0 ${
isSelected ? 'bg-emerald-400' : 'bg-slate-300'
}`}
/>
<div className="min-w-0">
<div className="text-xs truncate">{b.label}</div>
{b.range && (
<div
className={`text-[10px] ${
isSelected ? 'text-slate-300' : 'text-slate-400'
}`}
>
{b.range}
</div>
)}
</div>
</div>
<span
className={`text-[10px] font-bold px-2 py-0.5 rounded-full shrink-0 ${
isSelected
? 'bg-white/20 text-white'
: count > 0
? 'bg-amber-100 text-amber-800'
: 'bg-slate-100 text-slate-500'
}`}
>
{count} {count === 1 ? 'order' : 'orders'}
</span>
</button>
);
})}
</div>
</div>
)}
</div>
)}
</div>
</div>
@@ -3656,36 +3821,6 @@ const Dispatch = ({
</button>
</div>
{shouldFetchLive && viewMode !== 'rider-info' && (
<div id="batch-row">
<span className="batch-label">Batch</span>
{/* Inner scroller — keeps the "Slot" label fixed while the chip list scrolls
horizontally when it overflows. */}
<div className="batch-scroll">
{BATCHES.map((b) => {
const isActive = selectedBatch === b.id;
return (
<button
key={b.id}
ref={isActive ? activeBatchRef : null}
className={`batch-btn batch-slot ${isActive ? 'active' : ''}`}
onClick={() => {
setSelectedBatch(b.id);
handleRiderFocus(null);
setFocusedKitchen(null);
setFocusedZone(null);
}}
title={`${b.label} (${b.range})`}
>
<span className="batch-btn-label">{b.label}</span>
<span className="batch-btn-count">{batchCounts[b.id] ?? 0}</span>
</button>
);
})}
</div>
</div>
)}
{viewMode === 'profitability' ? (
<ProfitabilitySection
riders={riders}
@@ -4488,6 +4623,17 @@ const Dispatch = ({
</div>
);
})()}
<button
type="button"
className="zone-order-change-rider"
title="Change rider"
onClick={(e) => {
e.stopPropagation();
handleTriggerChangeRider(o, riderForOrder);
}}
>
<MdSwapHoriz />
</button>
</div>
<div className="zone-order-customer">
@@ -4670,6 +4816,17 @@ const Dispatch = ({
</div>
);
})()}
<button
type="button"
className="zone-order-change-rider"
title="Change rider"
onClick={(e) => {
e.stopPropagation();
handleTriggerChangeRider(o, riderForOrder);
}}
>
<MdSwapHoriz />
</button>
</div>
<div className="zone-order-customer">
@@ -6696,6 +6853,66 @@ const Dispatch = ({
</div>
</div>
)}
{/* STANDALONE CHANGE RIDER MODAL */}
{internalChangeDialogOpen && (
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-black/50 backdrop-blur-xs animate-in fade-in duration-150">
<div className="bg-white rounded-xl shadow-2xl border border-slate-200 w-full max-w-[420px] overflow-hidden animate-in zoom-in-95 duration-150">
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
<h2 className="text-base font-bold text-slate-900 tracking-tight">Change Rider</h2>
<button
type="button"
onClick={() => setInternalChangeDialogOpen(false)}
className="text-slate-400 hover:text-slate-600 rounded-md p-1 hover:bg-slate-100 transition-colors cursor-pointer"
>
<MdClose className="w-5 h-5" />
</button>
</div>
<div className="p-5 space-y-4">
<p className="text-xs text-slate-600">
Move order #{internalSelectedOrder?.orderid ?? internalSelectedOrder?.deliveryid} (step {internalSelectedOrder?.step ?? '—'}) to:
</p>
<div>
<label className="block text-xs font-semibold text-slate-800 mb-1.5">New rider</label>
<div className="relative">
<select
value={internalSelectedNewRider ? String(internalSelectedNewRider.userid ?? internalSelectedNewRider.id) : ''}
onChange={(e) => {
const opt = riderOptions.find((o) => o.value === e.target.value);
setInternalSelectedNewRider(opt?.rider || null);
}}
className="w-full h-10 pl-3 pr-9 bg-white border border-slate-200 rounded-lg text-xs font-medium text-slate-800 focus:outline-none focus:ring-2 focus:ring-black/10 focus:border-black appearance-none cursor-pointer shadow-xs transition-colors"
>
<option value="" disabled>Pick a rider</option>
{riderOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<MdExpandMore className="w-5 h-5 text-slate-400 absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none" />
</div>
</div>
</div>
<div className="px-5 py-3.5 bg-slate-50 border-t border-slate-100 flex items-center justify-end gap-2.5">
<button
type="button"
onClick={() => setInternalChangeDialogOpen(false)}
className="h-9 px-4 border border-slate-200 bg-white hover:bg-slate-100 text-slate-700 text-xs font-medium rounded-md transition-colors cursor-pointer"
>
Cancel
</button>
<button
type="button"
disabled={!internalSelectedNewRider || isAssigningMiler}
onClick={confirmInternalChangeRider}
className="h-9 px-5 bg-black hover:bg-neutral-800 active:scale-[0.98] text-white text-xs font-semibold rounded-md shadow-xs transition-all cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{isAssigningMiler ? 'Assigning…' : 'Change Rider'}
</button>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -4,25 +4,16 @@ import { useLocation, useNavigate } from 'react-router-dom';
import logger from '../../../utils/logger';
import { useMutation, useQuery } from '@tanstack/react-query';
import dayjs from 'dayjs';
import { HiOutlineArrowLeft } from 'react-icons/hi';
import { IoReload } from 'react-icons/io5';
import { MdTwoWheeler, MdSwapHoriz, MdWarning, MdArrowBack } from 'react-icons/md';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Center } from '@astryxdesign/core/Center';
import { Card } from '@astryxdesign/core/Card';
import { Text } from '@astryxdesign/core/Text';
import { Heading } from '@astryxdesign/core/Heading';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { Badge } from '@astryxdesign/core/Badge';
import { Banner } from '@astryxdesign/core/Banner';
import { Selector } from '@astryxdesign/core/Selector';
import { Tooltip } from '@astryxdesign/core/Tooltip';
import { TabList, Tab } from '@astryxdesign/core/TabList';
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import {
ArrowLeft,
RotateCcw,
ChevronDown,
X,
ArrowRightLeft,
Loader2,
Bike,
AlertTriangle
} from 'lucide-react';
import {
buildMilerLookup,
@@ -40,55 +31,37 @@ import CircularLoader from 'components/CircularLoader';
import Dispatch from './Dispatch';
import { stepColor, useIsNarrowViewport } from './dispatchShared';
import { STATUS } from 'themes/dt/tokens';
import { AccentAvatar } from 'themes/dt/primitives';
// Numbered route-step marker. Kept as a small custom component rather than an
// Astryx primitive: it is a sequence marker carrying a per-step palette colour
// plus an unverified-rider warning overlay, which no Astryx component models.
// `isUnverified` is tinted from the STATUS palette rather than a raw hex.
// Numbered route-step marker
function StepChip({ order, stepNum, isUnverified, onClick }) {
const color = isUnverified ? STATUS.error : stepColor(Number(stepNum) - 1);
const hint = isUnverified ? 'Rider not recognized — click to assign a real rider' : 'Click to change rider';
return (
<Tooltip content={`Order #${order.orderid} · ${order.deliveryaddress || order.deliverysuburb || ''} — ${hint}`}>
{/* A real <button>, not a styled Center: this is an interactive control,
so the native element gives keyboard activation and focus for free.
Astryx's Center has no `as` prop, and its own Button can't express a
36px circular sequence marker. */}
<div className="relative group inline-block">
<button
type="button"
onClick={onClick}
aria-label={`Step ${stepNum}, order ${order.orderid}. ${hint}`}
className="relative inline-flex items-center justify-center w-9 h-9 p-0 rounded-full font-extrabold text-xs text-white cursor-pointer shadow-xs transition-transform hover:scale-105 active:scale-95"
style={{
position: 'relative',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 36,
height: 36,
padding: 0,
borderRadius: '50%',
border: 'none',
backgroundColor: color,
color: '#fff',
fontWeight: 800,
fontSize: 14,
cursor: 'pointer',
boxShadow: isUnverified
? `0 0 0 2px #fff, 0 0 0 4px ${STATUS.error}`
: '0 0 0 2px rgba(255,255,255,0.6)'
: '0 0 0 2px rgba(255,255,255,0.8)'
}}
>
{stepNum}
{isUnverified && (
<MdWarning
size={14}
style={{ position: 'absolute', top: -5, right: -5, color: STATUS.error, background: '#fff', borderRadius: '50%' }}
<AlertTriangle
className="w-3.5 h-3.5 absolute -top-1 -right-1 text-red-600 bg-white rounded-full p-0.5 shadow-xs"
/>
)}
</button>
</Tooltip>
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1.5 hidden group-hover:block z-30 px-2 py-1 bg-slate-900 text-white text-[11px] rounded whitespace-nowrap pointer-events-none shadow-lg">
Order #{order.orderid} · {order.deliveryaddress || order.deliverysuburb || ''} — {hint}
</div>
</div>
);
}
@@ -111,11 +84,6 @@ const tuningTypes = [
const extractRiders = (previewData) => {
if (!previewData) return [];
const map = new Map();
// De-dupe by orderid across the whole tree. A rider can legitimately appear
// in multiple zones (one per delivery suburb), so the same rider_id is
// visited more than once. Without this guard, any stale copy left behind
// by applyReconcileResponse gets concatenated into the rider's orders and
// the same orderid is sent twice to /deliveries/createdeliveries.
const seenOrderIds = new Set();
const push = (riderId, riderName, orders) => {
if (riderId == null) return;
@@ -158,9 +126,6 @@ const extractRiders = (previewData) => {
const flattenRiders = (riders) => {
const out = [];
riders.forEach((r) => {
// Go backend types Deliveries.userid as int — coerce here so any
// upstream string (AI response, riders API, change-rider edit) gets
// normalised before the JSON body is built.
const ridNum = Number(r.rider_id);
const rid = Number.isFinite(ridNum) ? ridNum : r.rider_id;
(r.orders || []).forEach((o) => {
@@ -177,9 +142,6 @@ const flattenRiders = (riders) => {
};
// Move one order from oldRiderId -> newRiderId inside dispatchPreviewData.
// Mutates both the zones[].riders[].orders[] tree (so the Dispatch tab
// renders the change) AND the flat details[] list (so Assign Orders picks
// it up). Returns a NEW preview object (immutable update).
const moveOrderInPreviewData = (preview, { orderId, newRiderId, newRiderName }) => {
if (!preview) return preview;
const next = JSON.parse(JSON.stringify(preview));
@@ -209,11 +171,6 @@ const moveOrderInPreviewData = (preview, { orderId, newRiderId, newRiderName })
movedOrder = r.orders[oi];
r.orders.splice(oi, 1);
homeZoneIdx = zi;
// A rider left with zero orders after this move is a ghost entry —
// Dispatch's rider list renders every zone.riders[] entry
// unconditionally, so it would keep showing as a clickable
// 0-trips/0km/₹0 card with nothing inside once its last order is
// reassigned elsewhere. Drop it from the zone entirely.
if (r.orders.length === 0) {
zone.riders.splice(ri, 1);
}
@@ -256,9 +213,7 @@ const moveOrderInPreviewData = (preview, { orderId, newRiderId, newRiderName })
return next;
};
// Merge a reconcile-API response { riders:[{rider_id, orders}] } back into
// dispatchPreviewData. Replaces each rider's orders[] in zones (preserving
// zone containment), then rebuilds the flat details list from the new tree.
// Merge a reconcile-API response back into dispatchPreviewData
const applyReconcileResponse = (preview, response) => {
if (!preview || !Array.isArray(response?.riders)) return preview;
const next = JSON.parse(JSON.stringify(preview));
@@ -268,12 +223,6 @@ const applyReconcileResponse = (preview, response) => {
);
if (Array.isArray(next.zones) && next.zones.length) {
// Pass 1: wipe every existing copy of a responding rider's orders across
// ALL zones. The server's reconciled list is the single source of truth,
// and a rider can be present in multiple zones (one per delivery suburb).
// The previous "update first match, delete from map" loop left stale
// copies in the other zones, which extractRiders then concatenated into
// duplicate orderids — surfacing as duplicate deliveries on Assign.
next.zones.forEach((zone) => {
if (!Array.isArray(zone.riders)) return;
zone.riders.forEach((r) => {
@@ -282,9 +231,6 @@ const applyReconcileResponse = (preview, response) => {
});
});
// Pass 2: drop the reconciled orders onto the first zone that already
// lists the rider. If the rider isn't anywhere in the tree, append a
// fresh rider entry to zone[0].
newOrdersByRider.forEach((orders, riderKey) => {
let placed = false;
for (const zone of next.zones) {
@@ -309,10 +255,6 @@ const applyReconcileResponse = (preview, response) => {
}
});
// Same ghost-rider cleanup as moveOrderInPreviewData: if the reconcile
// response came back with an empty orders[] for a rider (every stop it
// had got reassigned elsewhere during reconciliation), don't leave that
// rider sitting in the tree as a 0-trips/0km/₹0 card with nothing inside.
next.zones.forEach((zone) => {
if (!Array.isArray(zone.riders)) return;
zone.riders = zone.riders.filter((r) => Array.isArray(r.orders) && r.orders.length > 0);
@@ -330,7 +272,6 @@ const applyReconcileResponse = (preview, response) => {
];
}
// Rebuild flat details from the updated zones->riders->orders tree.
const flatDetails = [];
next.zones.forEach((zone) => {
(zone.riders || []).forEach((r) => {
@@ -356,18 +297,8 @@ const Preview = () => {
const stateData = location.state || {};
const isMobile = useIsNarrowViewport();
// SINGLE SOURCE OF TRUTH: every Change Rider / Reconcile / Re-Assign goes
// through this state. The Dispatch tab renders from it, the Reconcile tab
// derives its rider list from it, and Assign Orders sends a flattened copy
// of it to the API.
const [dispatchPreviewData, setDispatchPreviewData] = useState(stateData.dispatchPreviewData || null);
// The AI response arrives via location.state, which the browser stores in
// history.state and persists across reloads. That means a reload of
// /dispatch/preview would re-hydrate the stale snapshot — including any
// pending edits the user thought they discarded. Bounce to /orders when
// there's no fresh response, and wipe the history snapshot once consumed
// so a later reload / back-forward also bounces instead of re-using it.
useEffect(() => {
if (!stateData.dispatchPreviewData) {
navigate('/doormile/orders', { replace: true });
@@ -376,18 +307,14 @@ const Preview = () => {
if (typeof window !== 'undefined' && window.history?.state) {
window.history.replaceState({ ...window.history.state, usr: null }, '');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const [csvExportData, setCsvExportData] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [tabValue, setTabValue] = useState(0);
const [reconcileLoading, setReconcileLoading] = useState(false);
const [hasReconciled, setHasReconciled] = useState(false);
// Tracks riders whose orders have been edited since the last AI response
// or successful reconcile. Only these are sent to the reconcile API — the
// server-side step re-ordering only needs to see what actually changed.
const [dirtyRiderIds, setDirtyRiderIds] = useState(() => new Set());
// Change-rider dialog state
@@ -402,42 +329,28 @@ const Preview = () => {
const autoRiders = stateData.autoRiders || [];
const absentRidersPayload = stateData.absentRidersPayload || [];
// fetchRidersList() takes no params — GET /admin/milers is tenant-scoped
// server-side from the auth token, not by appId. Gating this query on
// `!!appId` was wrong: orders.js (the entry point into this page) always
// navigates here with a hardcoded appId of 0 (it has no zone picker at
// all), which made this query permanently disabled unless a stale
// `applocationid` happened to be cached in localStorage from a previous
// Dispatch.js visit — the Change Rider dropdown showed "no riders" for
// anyone who reached this page the normal way.
const { data: ridersList } = useQuery({
queryKey: ['ridersList'],
queryFn: fetchRidersList,
staleTime: 5 * 60 * 1000
});
// Options for the Change Rider picker. Astryx's Selector is value-based
// (string in, string out) where MUI's Autocomplete handed back the whole
// option object, so each entry keeps a reference to its raw rider — that is
// what `confirmChangeRider` reads (`.userid`, `.label`, `.firstname`,
// `.lastname`), and that function is frozen logic for this conversion.
const riderOptions = useMemo(
() =>
(ridersList || []).map((r) => ({
value: String(r.userid),
label: r.label || `${r.firstname || ''} ${r.lastname || ''}`.trim() || `Rider ${r.userid}`,
rider: r
})),
(ridersList || []).map((r) => {
const id = r.userid ?? r.milerprofileid ?? r.id ?? r.milerid;
const name = r.label || r.displayname || r.authname || `${r.firstname || ''} ${r.lastname || ''}`.trim() || `Rider ${id}`;
return {
value: String(id),
label: name,
rider: { ...r, userid: id, id }
};
}),
[ridersList]
);
// Derived: rider list for the Reconcile tab. Recomputes whenever the cache
// (dispatchPreviewData) changes — so Change Rider / Reconcile both reflect
// here without a separate state.
const reconcileRiders = useMemo(() => extractRiders(dispatchPreviewData), [dispatchPreviewData]);
// Derived: flat orders list used for the Assign Orders payload + CSV export.
// Always reflects the latest cache state.
const finaldeliveryList = useMemo(() => {
const flat = flattenRiders(reconcileRiders);
if (flat.length) return computeDeliveryAmounts(flat);
@@ -447,15 +360,6 @@ const Preview = () => {
return [];
}, [reconcileRiders, dispatchPreviewData]);
// Which orders carry a rider id the AI solver assigned that doesn't match
// any real Doormile miler (userid/milerprofileid/name — same rule
// finalCreatedeliveries uses server-side, see api.js). Bike-hypertuning
// mode never sends the solver a rider pool at all, so it assigns from its
// own internal roster — one that predates the Doormile migration and can
// return ids with no live counterpart. Surfacing this here, before
// commit, lets the operator fix it via the existing Change Rider flow
// instead of the batch silently failing (or worse, notifying/assigning
// the wrong person) after Assign is clicked.
const unverifiedOrderIds = useMemo(() => {
const lookup = buildMilerLookup(ridersList || []);
const ids = new Set();
@@ -492,13 +396,6 @@ const Preview = () => {
mutationFn: notifyRider,
onSuccess: () => OpenToast('Notification sent Successfully', 'success', 2000),
onError: (error) => {
// doormileAxios's response interceptor rewrites a failed request's
// rejection to `error.response.data` directly (see utils/doormileAxios.js),
// so `error` here IS the backend's JSON body, not an axios Error — its
// `.message` key only exists if the backend happened to name the field
// that. Logging the raw object is the only reliable way to see what a
// 400 actually complained about (e.g. "no device token", "invalid
// miler") instead of a blank/undefined toast.
logger.error('notifyRiderMutation failed:', error);
OpenToast(error?.message || error?.error || 'Failed to notify rider — see console for details', 'error', 2000);
}
@@ -507,8 +404,7 @@ const Preview = () => {
const createDeliveryMutation = useMutation({
mutationFn: aiMode == 0 ? createOptimisationDeliveries : createAutomationDeliveries,
onSuccess: (data) => {
OpenToast('Orders Optimised Successfully', 'success', 2000);
// Brand new response = brand new source of truth.
OpenToast('Orders Optimised Successfully', 'success', 2000);
setDispatchPreviewData(data);
setHasReconciled(false);
setDirtyRiderIds(new Set());
@@ -522,28 +418,10 @@ const Preview = () => {
});
const createFinalDeliveryMutation = useMutation({
// finalCreatedeliveries now assigns each order directly on Doormile's
// own booking record (POST /admin/bookings/:id/assign-miler per order,
// resolved internally) instead of posting to jupiter — see api.js for
// why: jupiter's createdeliveries wrote into jupiter's own orphaned
// database, which neither the Orders "pending" list nor the Deliveries
// "dispatched" filter ever read (both come from GET /admin/bookings).
mutationFn: finalCreatedeliveries,
onSuccess: (data) => {
OpenToast('Delivery Created Successfully', 'success', 2000);
setIsLoading(false);
// stateData.rider (a single rider forwarded via navigate() from the
// Orders page) is never actually populated in the real flow — that
// page's navigate() call doesn't include a `rider` key at all — so
// this was a permanent no-op and no rider ever got notified after
// assignment. Notify every rider finalCreatedeliveries actually
// resolved and assigned (data.resolvedMilerProfileIds — real
// milerprofileids from GET /admin/milers, deduped there). Previously
// this notified using order.rider_id/userid directly, which is the
// solver's own internal rider numbering — confirmed live to NOT be a
// real Doormile userid or milerprofileid (see api.js's
// finalCreatedeliveries) — so every notification went out with a
// bogus id and likely silently failed server-side.
(data?.resolvedMilerProfileIds || []).forEach((milerprofileid) => {
notifyRiderMutation.mutate(milerprofileid);
});
@@ -565,21 +443,15 @@ const Preview = () => {
onSuccess: (data) => {
logger.debug('reconcile: response', data);
if (Array.isArray(data?.riders)) {
// Merge: applyReconcileResponse replaces orders for riders present
// in the response and leaves the rest of the cache untouched.
setDispatchPreviewData((prev) => applyReconcileResponse(prev, data));
setHasReconciled(true);
// Clear only the riders we just reconciled from the dirty set, so
// any unrelated edits made meanwhile are preserved.
setDirtyRiderIds((prev) => {
const next = new Set(prev);
data.riders.forEach((r) => next.delete(String(r.rider_id)));
logger.debug('reconcile: dirtyRiderIds after clearing reconciled riders', [...next]);
return next;
});
OpenToast('Steps reconciled — preview updated', 'success', 2000);
} else {
logger.error('reconcile: response had no riders array — dirtyRiderIds NOT cleared, Assign Orders stays disabled', data);
OpenToast('Reconcile returned no rider data', 'warning', 3000);
}
},
@@ -619,20 +491,10 @@ const Preview = () => {
OpenToast('No deliveries to assign', 'error', 3000);
return;
}
// "Change Rider" and "Reconcile" were two independent buttons with
// nothing linking them — an operator could edit a rider's steps and hit
// Assign Orders without ever pressing Reconcile. Committing stale step
// ordering corrupts route sequences server-side (see this folder's
// CLAUDE.md §3, called out as the single biggest production risk here).
// dirtyRiderIds only ever holds riders edited since the last successful
// reconcile of that specific rider, so size > 0 means real unreconciled
// edits are pending.
if (dirtyRiderIds.size > 0) {
OpenToast(`Reconcile ${dirtyRiderIds.size} edited rider(s) before assigning`, 'warning', 4000);
return;
}
// Same reasoning as the button's disabled state — belt-and-suspenders
// in case this ever fires from somewhere other than that button.
if (unverifiedOrderIds.size > 0) {
OpenToast(`${unverifiedOrderIds.size} order(s) have an unrecognized rider — use Change Rider to fix them first`, 'warning', 4000);
return;
@@ -642,22 +504,14 @@ const Preview = () => {
};
const handleReconcile = () => {
logger.debug('handleReconcile: dirtyRiderIds', [...dirtyRiderIds], 'reconcileRiders ids', reconcileRiders.map((r) => r.rider_id));
if (!reconcileRiders.length) {
OpenToast('No riders to reconcile', 'warning', 3000);
return;
}
// Only send riders that were edited since the last AI response / reconcile.
// Their step ordering is the only thing that can be stale — untouched
// riders are skipped to keep the payload small.
const dirty = reconcileRiders.filter((r) =>
dirtyRiderIds.has(String(r.rider_id))
);
if (!dirty.length) {
logger.error(
'handleReconcile: dirtyRiderIds is non-empty but none of them match a rider currently in reconcileRiders — nothing to send, Assign Orders stays disabled',
[...dirtyRiderIds]
);
OpenToast('No edits to reconcile', 'info', 2500);
return;
}
@@ -680,11 +534,11 @@ const Preview = () => {
const confirmChangeRider = () => {
if (!selectedNewRider || !selectedOrder) return;
// Backend expects an int — coerce at the boundary so a string from the
// riders API doesn't propagate into the Assign Orders payload.
const newRiderId = Number(selectedNewRider.userid);
const newRiderId = Number(selectedNewRider.userid ?? selectedNewRider.id);
const newRiderName =
selectedNewRider.label ||
selectedNewRider.displayname ||
selectedNewRider.authname ||
`${selectedNewRider.firstname || ''} ${selectedNewRider.lastname || ''}`.trim() ||
`Rider ${newRiderId}`;
@@ -696,26 +550,10 @@ const Preview = () => {
});
setDispatchPreviewData(moved);
// If that was the old rider's LAST order, moveOrderInPreviewData's own
// ghost-rider cleanup already removed them from the tree entirely (see
// that function). There is nothing left of theirs to reconcile — and
// marking them dirty anyway is a real bug, not just unnecessary: the
// reconcile response can only ever echo back riders that were actually
// sent to it, handleReconcile only sends riders still present in
// reconcileRiders (derived from this same tree), so a rider who no
// longer exists here can NEVER be sent, NEVER come back in the
// response, and therefore NEVER get cleared from dirtyRiderIds —
// permanently stuck at size > 0, permanently disabling Assign Orders.
// Confirmed via logging (reconcile: dirtyRiderIds after clearing
// reconciled riders) that this is exactly what happens.
const oldRiderStillExists = Array.isArray(moved?.zones)
? moved.zones.some((z) => (z.riders || []).some((r) => String(r.rider_id ?? r.userid) === String(selectedOldRiderId)))
: false;
logger.debug('confirmChangeRider: old rider still has orders after move?', oldRiderStillExists, 'oldRiderId', selectedOldRiderId);
// Both riders' step sequences are now potentially stale: the old rider
// lost a stop, the new rider gained one. Mark both as dirty so the next
// Reconcile sends exactly these two — unless the old rider is gone.
setDirtyRiderIds((prev) => {
const next = new Set(prev);
if (selectedOldRiderId != null) {
@@ -723,7 +561,6 @@ const Preview = () => {
else next.delete(String(selectedOldRiderId));
}
if (newRiderId != null && Number.isFinite(newRiderId)) next.add(String(newRiderId));
logger.debug('confirmChangeRider: dirtyRiderIds after change', [...next]);
return next;
});
setHasReconciled(false);
@@ -732,111 +569,98 @@ const Preview = () => {
};
return (
// Mirrors Dispatch.css's `.dispatch-container` sizing exactly (this page
// embeds that same component below) — MainLayout's `.main-content-area`
// has 24px left/right/bottom padding and no padding-top (AppTopNav sits
// above it in normal flow), and exposes its own measured height via the
// --appshell-header-height CSS var. A bare `height: '100vh'` here doesn't
// subtract the top nav's height or cancel the surrounding padding, so the
// page's real content was taller than the visible viewport by roughly the
// nav height + padding — turning the whole page (not just the intended
// internal panels) into a scroll container, which dragged the rider
// sidebar along with it instead of it staying in place.
<VStack
gap={0}
padding={0}
style={{
height: 'calc(100vh - var(--appshell-header-height, 64px))',
margin: '0 -24px -24px -24px',
overflow: 'hidden',
position: 'relative'
}}
<div
className="flex flex-col bg-white overflow-hidden relative"
style={{ height: 'calc(100vh - 56px)' }}
>
{isLoading && (
<Center
style={{
position: 'absolute',
inset: 0,
zIndex: 1400,
backgroundColor: 'var(--color-overlay)'
}}
>
<div className="absolute inset-0 z-50 flex items-center justify-center bg-white/70 backdrop-blur-xs">
<CircularLoader color="inherit" />
</Center>
</div>
)}
<VStack
gap={0}
paddingInline={2}
paddingBlock={1.5}
style={{ borderBottom: '1px solid var(--color-border)' }}
>
<HStack justify="between" vAlign="center" wrap="wrap" gap={1.5}>
<HStack gap={1} vAlign="center">
<IconButton
label="Back to orders"
tooltip="Back to orders"
variant="ghost"
icon={<HiOutlineArrowLeft size={20} />}
onClick={() => navigate('/doormile/orders')}
/>
<Heading level={2}>Assign Orders</Heading>
</HStack>
{/* TOP BAR */}
<div className="bg-white border-b border-slate-200 px-4 py-2.5 flex items-center justify-between flex-wrap gap-2 shrink-0 z-20">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => navigate('/doormile/orders')}
className="p-1.5 -ml-1.5 text-slate-700 hover:text-black hover:bg-slate-100 rounded-lg transition-colors flex items-center gap-1 cursor-pointer"
title="Back to orders"
>
<ArrowLeft className="w-5 h-5" />
</button>
<h1 className="text-base font-bold text-slate-900 tracking-tight">Assign Orders</h1>
</div>
<HStack gap={1} vAlign="center" wrap="wrap">
<div style={{ minWidth: 220 }}>
<Selector
label="Hyper Tuning"
isLabelHidden
placeholder="Hyper Tuning"
options={tuningTypes.map((t) => ({ value: t.value, label: t.type }))}
onChange={(value) => handleCreateDelivery(value || null)}
/>
</div>
<Button
label="Re-Assign"
variant="primary"
icon={<IoReload />}
width={isMobile ? '100%' : undefined}
onClick={() => {
setIsLoading(true);
handleCreateDelivery('reshuffle');
<div className="flex items-center gap-2 flex-wrap">
<div className="relative min-w-[170px]">
<select
defaultValue=""
onChange={(e) => {
if (e.target.value) handleCreateDelivery(e.target.value);
}}
/>
<CSVExport
data={csvExportData}
filename={`Orders_Detail_${dayjs().format('YYYY-MM-DD_HHmmss')}.csv`}
label=" CSV"
/>
</HStack>
</HStack>
</VStack>
className="w-full h-9 pl-3 pr-8 bg-white border border-slate-200 hover:border-slate-300 rounded-md text-xs font-medium text-slate-700 focus:outline-none focus:ring-1 focus:ring-black appearance-none cursor-pointer shadow-xs transition-colors"
>
<option value="" disabled hidden>Hyper Tuning</option>
{tuningTypes.map((t) => (
<option key={t.tuneid} value={t.value}>{t.type}</option>
))}
</select>
<ChevronDown className="w-3.5 h-3.5 text-slate-400 absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none" />
</div>
<VStack gap={0} paddingInline={2} padding={0}>
<TabList
value={String(tabValue)}
onChange={(v) => setTabValue(Number(v))}
layout={isMobile ? 'fill' : 'hug'}
hasDivider
<button
type="button"
onClick={() => {
setIsLoading(true);
handleCreateDelivery('reshuffle');
}}
className="h-9 px-3.5 bg-black hover:bg-neutral-800 active:scale-[0.98] text-white text-xs font-semibold rounded-md shadow-xs flex items-center gap-1.5 transition-all cursor-pointer"
>
<RotateCcw className="w-3.5 h-3.5" />
<span>Re-Assign</span>
</button>
<CSVExport
data={csvExportData}
filename={`Orders_Detail_${dayjs().format('YYYY-MM-DD_HHmmss')}.csv`}
label="CSV"
/>
</div>
</div>
{/* SUB TABS BAR */}
<div className="bg-white border-b border-slate-200 px-4 flex items-center gap-6 shrink-0 z-10">
<button
type="button"
onClick={() => setTabValue(0)}
className={`py-2.5 text-xs font-semibold transition-all relative cursor-pointer ${
tabValue === 0 ? 'text-black border-b-2 border-black -mb-px' : 'text-slate-500 hover:text-slate-800'
}`}
>
<Tab value="0" label="Dispatch" />
<Tab value="1" label="Reconcile" />
</TabList>
</VStack>
Dispatch
</button>
<button
type="button"
onClick={() => setTabValue(1)}
className={`py-2.5 text-xs font-semibold transition-all relative flex items-center gap-1.5 cursor-pointer ${
tabValue === 1 ? 'text-black border-b-2 border-black -mb-px' : 'text-slate-500 hover:text-slate-800'
}`}
>
<span>Reconcile</span>
{dirtyRiderIds.size > 0 && (
<span className="px-1.5 py-0.5 text-[10px] font-bold bg-amber-100 text-amber-800 rounded-full">
{dirtyRiderIds.size}
</span>
)}
</button>
</div>
{/* minHeight 0 overrides the flex-item default of min-height: auto —
without it, this flex:1 child can grow past its allotted space to
fit its content's natural height instead of clipping to it, which
is what let the embedded Dispatch view (and its internal
overflow-y:auto sidebar) overflow into page-level scroll. */}
<VStack gap={0} padding={0} style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
{/* MAIN CONTENT (DISPATCH BOARD OR RECONCILE LIST) */}
<div className="flex-1 overflow-hidden min-h-0 flex flex-col relative">
{tabValue === 0 && dispatchPreviewData && (
<Dispatch
// The key forces a full re-mount when the cache reference changes
// (after Change Rider / Reconcile / Re-Assign) so Dispatch's
// internal state (focused rider, view mode, etc.) recomputes
// against the new orders. Without this, internal memos can stick
// to the previous data shape.
key={dispatchPreviewData?.__cacheKey || JSON.stringify(reconcileRiders.length)}
data={dispatchPreviewData}
embedded
@@ -845,47 +669,52 @@ const Preview = () => {
)}
{tabValue === 1 && (
<VStack gap={2} padding={2} isScrollable style={{ flex: 1 }}>
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-slate-50">
{reconcileRiders.length === 0 ? (
<EmptyState
title="Nothing to reconcile"
description="No rider data available to reconcile."
icon={<MdSwapHoriz size={28} />}
/>
<div className="flex flex-col items-center justify-center p-12 bg-white rounded-xl border border-slate-200 text-center">
<ArrowRightLeft className="w-8 h-8 text-slate-400 mb-2" />
<h3 className="text-sm font-semibold text-slate-800">Nothing to reconcile</h3>
<p className="text-xs text-slate-500 mt-1">No rider data available to reconcile.</p>
</div>
) : (
<>
<Banner
status={hasReconciled ? 'success' : 'warning'}
title={
<div
className={`p-3 rounded-lg border text-xs font-medium flex items-center gap-2 ${
hasReconciled
? 'Steps have been reconciled. The Dispatch tab and Assign payload are updated.'
: 'Click a numbered step to change its rider. Hit Reconcile to verify the corrected steps with the server.'
}
/>
? 'bg-emerald-50 border-emerald-200 text-emerald-800'
: 'bg-amber-50 border-amber-200 text-amber-800'
}`}
>
{hasReconciled
? 'Steps have been reconciled. The Dispatch tab and Assign payload are updated.'
: 'Click a numbered step to change its rider. Hit Reconcile to verify the corrected steps with the server.'}
</div>
{reconcileRiders.map((r) => {
const totalKms = r.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0);
return (
<Card key={r.rider_id} padding={3}>
<HStack justify="between" vAlign="center" wrap="wrap" gap={1}>
<HStack gap={1.5} vAlign="center">
<AccentAvatar color={STATUS.accepted} size={32}>
<MdTwoWheeler size={18} />
</AccentAvatar>
<VStack gap={0} padding={0}>
<Text weight="bold">{r.rider_name}</Text>
<Text size="sm" type="supporting">
ID: {r.rider_id}
</Text>
</VStack>
</HStack>
<HStack gap={1} vAlign="center">
<Badge variant="neutral" label={`${r.orders.length} stops`} />
<Badge variant="neutral" label={`${totalKms.toFixed(1)} km`} />
</HStack>
</HStack>
<div key={r.rider_id} className="bg-white border border-slate-200 rounded-xl p-4 shadow-xs">
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-indigo-600 text-white flex items-center justify-center font-bold text-xs">
<Bike className="w-4 h-4" />
</div>
<div>
<p className="text-xs font-bold text-slate-900">{r.rider_name}</p>
<p className="text-[11px] text-slate-500 font-normal">ID: {r.rider_id}</p>
</div>
</div>
<div className="flex items-center gap-1.5">
<span className="bg-slate-100 text-slate-700 text-[11px] font-medium px-2.5 py-1 rounded-md">
{r.orders.length} stops
</span>
<span className="bg-slate-100 text-slate-700 text-[11px] font-medium px-2.5 py-1 rounded-md">
{totalKms.toFixed(1)} km
</span>
</div>
</div>
<HStack gap={1.5} wrap="wrap" vAlign="center" style={{ marginTop: 'var(--spacing-6)' }}>
<div className="flex flex-wrap items-center gap-2 mt-4 pt-3 border-t border-slate-100">
{r.orders.map((o, idx) => (
<StepChip
key={`${o.orderid}-${idx}`}
@@ -895,117 +724,134 @@ const Preview = () => {
onClick={() => openChangeRider(r, o)}
/>
))}
</HStack>
</Card>
</div>
</div>
);
})}
<Center style={{ paddingTop: 12, paddingBottom: 16 }}>
<Button
label={
reconcileLoading
<div className="flex justify-center pt-3 pb-6">
<button
type="button"
disabled={reconcileLoading || dirtyRiderIds.size === 0}
onClick={handleReconcile}
className="h-10 px-8 bg-black hover:bg-neutral-800 active:scale-[0.98] text-white text-xs font-semibold rounded-md shadow-sm flex items-center gap-2 transition-all cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{reconcileLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <ArrowRightLeft className="w-4 h-4" />}
<span>
{reconcileLoading
? 'Reconciling…'
: dirtyRiderIds.size === 0
? 'Reconcile'
: `Reconcile (${dirtyRiderIds.size})`
}
variant="primary"
size="lg"
icon={<MdSwapHoriz />}
isLoading={reconcileLoading}
isDisabled={reconcileLoading || dirtyRiderIds.size === 0}
width={isMobile ? '100%' : 220}
onClick={handleReconcile}
/>
</Center>
: `Reconcile (${dirtyRiderIds.size})`}
</span>
</button>
</div>
</>
)}
</VStack>
</div>
)}
</VStack>
</div>
<VStack
gap={0}
paddingInline={2}
paddingBlock={1.5}
style={{ borderTop: '1px solid var(--color-border)' }}
>
<HStack gap={2} vAlign="center" justify="end" wrap="wrap">
<Button
label="Back"
variant="secondary"
icon={<MdArrowBack size={16} />}
width={isMobile ? '100%' : undefined}
onClick={() => navigate(-1)}
/>
{/* Assign stays disabled until every edited rider has been
reconciled and no order is left on an unrecognized rider. This
gating is the UI half of the reconcile-before-assign rule (see
this folder's CLAUDE.md §3) — do not relax it. Button uses
aria-disabled when a tooltip is present, so it stays focusable
and the reason remains discoverable. */}
<Tooltip
content={
dirtyRiderIds.size > 0
{/* BOTTOM FIXED ACTION BAR */}
<div className="bg-white border-t border-slate-200 px-4 py-2.5 flex items-center justify-end gap-2.5 shrink-0 shadow-[0_-2px_10px_rgba(0,0,0,0.03)] z-30">
<button
type="button"
onClick={() => navigate(-1)}
className="h-9 px-4 border border-slate-200 bg-white hover:bg-slate-50 text-slate-700 text-xs font-medium rounded-md shadow-xs flex items-center gap-1.5 transition-all cursor-pointer"
>
<ArrowLeft className="w-3.5 h-3.5" />
<span>Back</span>
</button>
<div className="relative group">
<button
type="button"
disabled={dirtyRiderIds.size > 0 || unverifiedOrderIds.size > 0 || isLoading}
onClick={handleFinalCreateDelivery}
className="h-9 px-5 bg-black hover:bg-neutral-800 active:scale-[0.98] text-white text-xs font-semibold rounded-md shadow-xs flex items-center gap-2 transition-all cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
<span>Assign Orders</span>
</button>
{(dirtyRiderIds.size > 0 || unverifiedOrderIds.size > 0) && (
<div className="absolute right-0 bottom-full mb-2 hidden group-hover:block w-64 p-2 bg-slate-900 text-white text-[11px] rounded shadow-lg pointer-events-none z-50 leading-tight">
{dirtyRiderIds.size > 0
? `Reconcile ${dirtyRiderIds.size} edited rider(s) first`
: unverifiedOrderIds.size > 0
? `Fix ${unverifiedOrderIds.size} order(s) with an unrecognized rider first`
: 'Commit these assignments'
}
>
<Button
label="Assign Orders"
variant="primary"
width={isMobile ? '100%' : undefined}
isDisabled={dirtyRiderIds.size > 0 || unverifiedOrderIds.size > 0}
onClick={handleFinalCreateDelivery}
/>
</Tooltip>
</HStack>
</VStack>
? `Fix ${unverifiedOrderIds.size} order(s) with unrecognized rider first`
: ''}
</div>
)}
</div>
</div>
<Dialog
isOpen={changeDialogOpen}
onOpenChange={(next) => !next && setChangeDialogOpen(false)}
width={420}
purpose="form"
>
<DialogHeader title="Change Rider" />
<VStack gap={3} padding={4}>
<Text type="supporting">
Move order #{selectedOrder?.orderid} (step {selectedOrder?.step ?? '—'}) to:
</Text>
<Selector
label="New rider"
placeholder="Pick a rider"
options={riderOptions}
value={selectedNewRider ? String(selectedNewRider.userid) : null}
onChange={(value) => setSelectedNewRider(riderOptions.find((o) => o.value === value)?.rider ?? null)}
/>
<HStack gap={2} justify="end" wrap="wrap">
<Button
label="Cancel"
variant="secondary"
width={isMobile ? '100%' : undefined}
onClick={() => setChangeDialogOpen(false)}
/>
<Button
label="Change Rider"
variant="primary"
width={isMobile ? '100%' : undefined}
isDisabled={!selectedNewRider}
onClick={confirmChangeRider}
/>
</HStack>
</VStack>
</Dialog>
</VStack>
{/* CHANGE RIDER MODAL (IMAGE 4) */}
{changeDialogOpen && (
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-black/50 backdrop-blur-xs animate-in fade-in duration-150">
<div className="bg-white rounded-xl shadow-2xl border border-slate-200 w-full max-w-[420px] overflow-hidden animate-in zoom-in-95 duration-150">
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
<h2 className="text-base font-bold text-slate-900 tracking-tight">Change Rider</h2>
<button
type="button"
onClick={() => setChangeDialogOpen(false)}
className="text-slate-400 hover:text-slate-600 rounded-md p-1 hover:bg-slate-100 transition-colors cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="p-5 space-y-4">
<p className="text-xs text-slate-600">
Move order #{selectedOrder?.orderid} (step {selectedOrder?.step ?? '—'}) to:
</p>
<div>
<label className="block text-xs font-semibold text-slate-800 mb-1.5">New rider</label>
<div className="relative">
<select
value={selectedNewRider ? String(selectedNewRider.userid ?? selectedNewRider.id) : ''}
onChange={(e) => {
const opt = riderOptions.find((o) => o.value === e.target.value);
setSelectedNewRider(opt?.rider || null);
}}
className="w-full h-10 pl-3 pr-9 bg-white border border-slate-200 rounded-lg text-xs font-medium text-slate-800 focus:outline-none focus:ring-2 focus:ring-black/10 focus:border-black appearance-none cursor-pointer shadow-xs transition-colors"
>
<option value="" disabled>Pick a rider</option>
{riderOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<ChevronDown className="w-4 h-4 text-slate-400 absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none" />
</div>
</div>
</div>
<div className="px-5 py-3.5 bg-slate-50 border-t border-slate-100 flex items-center justify-end gap-2.5">
<button
type="button"
onClick={() => setChangeDialogOpen(false)}
className="h-9 px-4 border border-slate-200 bg-white hover:bg-slate-100 text-slate-700 text-xs font-medium rounded-md transition-colors cursor-pointer"
>
Cancel
</button>
<button
type="button"
disabled={!selectedNewRider}
onClick={confirmChangeRider}
className="h-9 px-5 bg-black hover:bg-neutral-800 active:scale-[0.98] text-white text-xs font-semibold rounded-md shadow-xs transition-all cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
Change Rider
</button>
</div>
</div>
</div>
)}
</div>
);
};
// Mirrors the orders.js deliveryamt recalc — applied at render-time so the
// Assign payload always reflects the current cache without a useEffect.
// Mirrors the orders.js deliveryamt recalc
function computeDeliveryAmounts(list) {
return list.map((item) => {
const cumulativeKms = Number(item.cumulativekms || 0);

View File

@@ -2,75 +2,37 @@ import React, { useState, useMemo, useCallback } from 'react';
import PropTypes from 'prop-types';
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
import {
MdTrendingUp,
MdTrendingDown,
MdReceipt,
MdPayments,
MdRoute,
MdLocationOn,
MdPeopleAlt,
MdStraighten,
MdAccountBalanceWallet,
MdOutlineSavings
} from 'react-icons/md';
import { Grid } from '@astryxdesign/core/Grid';
import { KpiGrid, TABLE_FRAME } from 'components/nearle_components/PageLayout';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Center } from '@astryxdesign/core/Center';
import { Card } from '@astryxdesign/core/Card';
import { Text } from '@astryxdesign/core/Text';
import { Heading } from '@astryxdesign/core/Heading';
import { Collapsible } from '@astryxdesign/core/Collapsible';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import { ProgressBar } from '@astryxdesign/core/ProgressBar';
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
import StatCard from 'components/nearle_components/StatCard';
TrendingUp,
TrendingDown,
Receipt,
CreditCard,
Route as RouteIcon,
MapPin,
Users,
Ruler,
Wallet,
PiggyBank,
ChevronDown,
ChevronUp
} from 'lucide-react';
import StatusBadge from 'components/nearle_components/StatusBadge';
import { STATUS } from 'themes/dt/tokens';
import { AccentAvatar, TableScroll } from 'themes/dt/primitives';
// ─────────────────────────────────────────────────────────────
// Constants
// ─────────────────────────────────────────────────────────────
// Exported so reports/profitability.js can reuse the exact same profit math
// instead of hand-duplicating it (a duplication that had already drifted
// into a sync-risk between the two files).
/** Revenue rule: ₹30 base for ≤8 km, ₹6/km beyond. */
export const BASE_REVENUE = 30;
export const BASE_KM_LIMIT = 8;
export const EXTRA_RATE_KM = 6;
/** Fixed salary cost sliced per slot (₹5000 / 30 days / 1 slot). */
export const FIXED_COST_PER_SLOT = 500 / 3;
/** Variable fuel / wear cost per km. */
export const VARIABLE_RATE_KM = 2.5;
// Status colours previously lived in a local STATUS_MAP here — the third rival
// status table in this folder. It disagreed with both of the others (it painted
// `active` sky-blue and had an `assigned` state the canonical table calls
// `accepted`), so the same order could render three different colours depending
// on which panel drew it. Statuses now render via <StatusBadge>, which reads
// themes/dt/status.js. See this folder's CLAUDE.md §1b.
// ─────────────────────────────────────────────────────────────
// Pure helpers
// ─────────────────────────────────────────────────────────────
function orderRevenue(order) {
const km = parseFloat(order.riderkms || 0);
return km <= BASE_KM_LIMIT ? BASE_REVENUE : BASE_REVENUE + (km - BASE_KM_LIMIT) * EXTRA_RATE_KM;
}
// NOTE: a fourth copy of the batch windows. Dispatch.js (BATCHES_DEFAULT_RAW),
// deliveries.js and this file must agree — see the dispatch folder's CLAUDE.md
// §1. reports/profitability.js imports this one, which is why it stays exported
// here rather than moving wholesale into dispatchShared.
export const BATCHES_DEFAULT = [
{ id: 'morning', name: 'Morning Batch', startHour: 0, endHour: 8 },
{ id: 'afternoon', name: 'Afternoon Batch', startHour: 9, endHour: 12.5 },
@@ -84,16 +46,6 @@ const getBatchForHour = (h, batches = BATCHES_DEFAULT) => {
return null;
};
// The row field a slot is derived from. Must match Dispatch.js's
// BATCH_TIME_FIELD ('created' → `orderdate`) and deliveries.js's
// BATCH_TIME_KEYS.
//
// This used to read `assigntime || deliverydate` with a bare `dayjs()`. Both
// halves were wrong: `assigntime` is mapped to the booking's last-modified
// column (api.js), so it is re-stamped by any status change and a rider's slot
// count drifted through the day; and bare `dayjs()` mis-parses the false
// trailing Z some Doormile timestamps carry, shifting a row +5:30 into a
// different slot. Slot count feeds the FIXED cost, so both bugs moved money.
const getRowBatch = (r, batches = BATCHES_DEFAULT) => {
const t = r?.orderdate;
if (!t) return null;
@@ -104,8 +56,6 @@ const getRowBatch = (r, batches = BATCHES_DEFAULT) => {
return getBatchForHour(d.hour() + d.minute() / 60, batches);
};
// The calendar day a row belongs to — same field as the bucketing above, and
// the same field api.js's fetchDeliveries filters its date range on.
const rowDay = (o) => {
const t = o?.orderdate;
if (!t) return null;
@@ -113,16 +63,9 @@ const rowDay = (o) => {
return d.isValid() ? d.format('YYYY-MM-DD') : null;
};
// Orders in these statuses never delivered, so they earn no revenue and
// shouldn't be charged fuel/slot cost either — including them here made
// every rider's profit/margin KPI count cancelled and skipped stops as if
// they'd been completed.
const EXCLUDED_FROM_PROFIT_STATUSES = new Set(['cancelled', 'skipped']);
function calcRiderMetrics(rider, selectedDate, batches) {
// `orders` stays the full date-filtered set — the breakdown table and
// order-count KPI intentionally still show cancelled/skipped stops for
// context. Only the money/km math below excludes them.
const orders = (rider.orders ?? []).filter((o) => {
if (!selectedDate) return true;
return rowDay(o) === selectedDate;
@@ -165,7 +108,6 @@ function calcRiderMetrics(rider, selectedDate, batches) {
return { revenue, kms, varCost, fixedCost, totalCost, net, margin, orders };
}
/** Format a km value, switching to metres below 1 km so short hops don't round to "0.0 km". */
function formatDistance(km) {
const value = Number.isFinite(km) ? km : 0;
if (value > 0 && value < 1) {
@@ -196,256 +138,224 @@ function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
// Money is not a lifecycle status, so it must NOT go through StatusBadge —
// it reads the raw hexes from the STATUS palette instead, which is the
// sanctioned "arbitrary accent" channel (root CLAUDE.md §6.3).
const moneyColor = (isProfit) => (isProfit ? STATUS.success : STATUS.error);
// ─────────────────────────────────────────────────────────────
// Sub-components
// ─────────────────────────────────────────────────────────────
/** 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 (
<VStack gap={0.5} padding={0}>
<Text weight="bold" maxLines={1}>
{name}
</Text>
{phone && (
<Text size="sm" type="supporting">
{phone}
</Text>
)}
<div className="space-y-0.5 min-w-0">
<p className="text-xs font-bold text-slate-900 truncate">{name}</p>
{phone && <p className="text-[11px] text-slate-500 truncate">{phone}</p>}
{location && (
<HStack gap={0.5} vAlign="center">
<Center isInline width={12} height={12} style={{ color: 'var(--color-icon-secondary)' }}>
<MdLocationOn size={12} />
</Center>
<Text size="sm" type="supporting" maxLines={1}>
{location}
</Text>
</HStack>
<p className="text-[11px] text-slate-500 flex items-center gap-1 truncate">
<MapPin className="w-3 h-3 text-slate-400 shrink-0" />
<span className="truncate">{location}</span>
</p>
)}
</VStack>
</div>
);
}
/** Orders table inside an expanded rider card. */
function OrdersBreakdownTable({ orders, getRevenue }) {
if (!orders?.length) {
return <EmptyState title="No orders yet" description="No orders assigned to this rider yet." icon={<MdReceipt size={24} />} />;
return (
<div className="p-6 text-center bg-slate-50 rounded-lg border border-slate-100">
<Receipt className="w-6 h-6 text-slate-400 mx-auto mb-1.5" />
<p className="text-xs font-semibold text-slate-700">No orders yet</p>
<p className="text-[11px] text-slate-500">No orders assigned to this rider yet.</p>
</div>
);
}
return (
<TableScroll minWidth={720} maxHeight={360}>
<Table density="compact" dividers="rows" hasHover>
<TableHeader>
<TableRow>
<TableHeaderCell>Customer</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Planned KMs</TableHeaderCell>
<TableHeaderCell>Actual KMs</TableHeaderCell>
<TableHeaderCell>Trip KMs</TableHeaderCell>
<TableHeaderCell>Revenue</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
<div className="overflow-x-auto rounded-lg border border-slate-200 bg-white">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-slate-50 border-b border-slate-200 text-slate-600 font-semibold">
<th className="p-3">Customer</th>
<th className="p-3">Status</th>
<th className="p-3 text-right">Planned KMs</th>
<th className="p-3 text-right">Actual KMs</th>
<th className="p-3 text-right">Trip KMs</th>
<th className="p-3 text-right">Revenue</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{orders.map((order, idx) => (
<TableRow key={`${order.orderid ?? 'order'}-${idx}`}>
<TableCell>
<tr key={`${order.orderid ?? 'order'}-${idx}`} className="hover:bg-slate-50/80 transition-colors">
<td className="p-3">
<CustomerCell order={order} />
</TableCell>
<TableCell>
</td>
<td className="p-3">
<StatusBadge status={order.orderstatus ?? order.status} />
</TableCell>
<TableCell>
<Text hasTabularNumbers>{formatDistance(parseFloat(order.kms ?? 0))}</Text>
</TableCell>
<TableCell>
<Text hasTabularNumbers>{formatDistance(parseFloat(order.actualkms ?? 0))}</Text>
</TableCell>
<TableCell>
<Text hasTabularNumbers>{formatDistance(parseFloat(order.riderkms ?? 0))}</Text>
</TableCell>
<TableCell>
<Text weight="bold" hasTabularNumbers style={{ textAlign: 'right' }}>
{rupees(getRevenue(order))}
</Text>
</TableCell>
</TableRow>
</td>
<td className="p-3 text-right tabular-nums text-slate-700">
{formatDistance(parseFloat(order.kms ?? 0))}
</td>
<td className="p-3 text-right tabular-nums text-slate-700">
{formatDistance(parseFloat(order.actualkms ?? 0))}
</td>
<td className="p-3 text-right tabular-nums text-slate-700">
{formatDistance(parseFloat(order.riderkms ?? 0))}
</td>
<td className="p-3 text-right font-bold tabular-nums text-slate-900">
{rupees(getRevenue(order))}
</td>
</tr>
))}
</TableBody>
</Table>
</TableScroll>
</tbody>
</table>
</div>
);
}
/** One cost tile inside the expanded panel. */
function CostTile({ icon, color, label, amount, footnote }) {
return (
<Card padding={3}>
<HStack gap={2} vAlign="center">
<Center
width={34}
height={34}
style={{ flexShrink: 0, borderRadius: 'var(--radius-element)', backgroundColor: `${color}14`, color }}
>
{icon}
</Center>
<VStack gap={0} padding={0}>
<Text size="sm" type="supporting">
{label}
</Text>
<Text weight="bold" hasTabularNumbers style={{ color }}>
{amount}
</Text>
<Text size="sm" type="supporting">
{footnote}
</Text>
</VStack>
</HStack>
</Card>
<div className="p-3.5 bg-white rounded-xl border border-slate-200 shadow-xs flex items-center gap-3">
<div
className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
style={{ backgroundColor: `${color}18`, color }}
>
{icon}
</div>
<div className="min-w-0">
<p className="text-[11px] font-medium text-slate-500">{label}</p>
<p className="text-sm font-bold tabular-nums" style={{ color }}>
{amount}
</p>
<p className="text-[10.5px] text-slate-400 truncate">{footnote}</p>
</div>
</div>
);
}
/** Expanded cost breakdown + orders for one rider. */
function RiderDetailPanel({ metrics }) {
const { varCost, fixedCost, kms, net, margin } = metrics;
const isProfit = net >= 0;
const orderCount = metrics.orders?.length ?? 0;
return (
<VStack gap={3} padding={3}>
<Grid columns={{ minWidth: 200, repeat: 'fit' }} gap={3}>
<div className="p-4 bg-slate-50/60 border-t border-slate-100 space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<CostTile
icon={<MdPayments size={16} />}
icon={<CreditCard className="w-4 h-4" />}
color={STATUS.info}
label="Fixed cost"
amount={rupees(fixedCost, 2)}
footnote="Salary (per slot)"
/>
<CostTile
icon={<MdRoute size={16} />}
icon={<RouteIcon className="w-4 h-4" />}
color={STATUS.picked}
label="Variable cost"
amount={rupees(varCost, 2)}
footnote={`${kms.toFixed(1)} km × ₹${VARIABLE_RATE_KM}/km`}
/>
<CostTile
icon={isProfit ? <MdTrendingUp size={16} /> : <MdTrendingDown size={16} />}
icon={isProfit ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
color={moneyColor(isProfit)}
label={isProfit ? 'Net profit' : 'Net loss'}
amount={`${isProfit ? '+' : ''}${rupees(net, 2)}`}
footnote={`${Math.abs(margin).toFixed(0)}% margin`}
/>
</Grid>
</div>
<VStack gap={2} padding={0}>
<HStack justify="between" vAlign="center">
<HStack gap={1} vAlign="center">
<Center isInline width={14} height={14} style={{ color: 'var(--color-icon-secondary)' }}>
<MdReceipt size={14} />
</Center>
<Text weight="bold">Revenue breakdown</Text>
</HStack>
<Text size="sm" type="supporting">
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-xs font-bold text-slate-800">
<Receipt className="w-3.5 h-3.5 text-slate-500" />
<span>Revenue breakdown</span>
</div>
<span className="text-[11px] text-slate-500 font-medium">
{orderCount} order{orderCount !== 1 ? 's' : ''}
</Text>
</HStack>
</span>
</div>
<OrdersBreakdownTable orders={metrics.orders} getRevenue={orderRevenue} />
</VStack>
</VStack>
</div>
</div>
);
}
/** Single expandable rider profitability row. */
function RiderProfitabilityCard({ rider, metrics, isExpanded, isFocused, onToggle, onFocus }) {
const { revenue, kms, totalCost, net, margin, orders } = metrics;
const isProfit = net >= 0;
const orderCount = orders?.length ?? 0;
const name = rider.riderName ?? rider.username ?? `Rider #${rider.id}`;
const handleOpenChange = () => {
const handleToggle = () => {
onToggle(rider.id);
if (!isFocused && onFocus) onFocus(rider);
};
// The resting frame matches every other table in the app. The focused state
// keeps its accent outline — that is a selection affordance, not decoration,
// and the brief was explicit about leaving selection alone.
return (
<Card
padding={0}
elevation="none"
style={{ border: TABLE_FRAME, ...(isFocused ? { outline: '2px solid var(--color-accent)' } : null) }}
<div
className={`bg-white rounded-xl border transition-all ${
isFocused ? 'border-black ring-1 ring-black' : 'border-slate-200 hover:border-slate-300'
} shadow-xs overflow-hidden`}
>
<Collapsible
isOpen={isExpanded}
onOpenChange={handleOpenChange}
trigger={
<HStack gap={3} vAlign="center" wrap="wrap" padding={3}>
<AccentAvatar color={moneyColor(isProfit)} selected size={36}>
<Text size="sm" weight="bold" style={{ color: 'inherit' }}>
{riderInitials(name)}
</Text>
</AccentAvatar>
<VStack gap={0} padding={0}>
<Text weight="bold" maxLines={1}>
{name}
</Text>
<Text size="sm" type="supporting">
{orderCount} order{orderCount !== 1 ? 's' : ''} · {kms.toFixed(1)} km total
</Text>
</VStack>
<HStack gap={4} vAlign="center" wrap="wrap" style={{ marginInlineStart: 'auto' }}>
<VStack gap={0} padding={0} hAlign="end">
<Text size="sm" type="supporting">
Revenue
</Text>
<Text weight="bold" hasTabularNumbers>
{rupees(revenue)}
</Text>
</VStack>
<VStack gap={0} padding={0} hAlign="end">
<Text size="sm" type="supporting">
Cost
</Text>
<Text weight="bold" hasTabularNumbers>
{rupees(totalCost)}
</Text>
</VStack>
<VStack gap={0.5} padding={0} hAlign="end" width={120}>
<Text size="sm" type="supporting">
Net
</Text>
<Text weight="bold" hasTabularNumbers style={{ color: moneyColor(isProfit) }}>
{isProfit ? '+' : ''}
{rupees(net)}
</Text>
{/* Margin bar — a plain magnitude readout, so the value is the
absolute margin clamped to 100 exactly as the old CSS bar was. */}
<ProgressBar
value={clamp(Math.abs(margin), 0, 100)}
max={100}
variant={isProfit ? 'success' : 'error'}
label={`${Math.abs(margin).toFixed(0)}% margin`}
isLabelHidden
/>
</VStack>
</HStack>
</HStack>
}
<button
type="button"
onClick={handleToggle}
className="w-full p-4 flex items-center justify-between flex-wrap gap-3 text-left cursor-pointer hover:bg-slate-50/50 transition-colors"
>
<RiderDetailPanel metrics={metrics} />
</Collapsible>
</Card>
<div className="flex items-center gap-3 min-w-[200px]">
<div
className="w-9 h-9 rounded-full flex items-center justify-center font-bold text-xs text-white shrink-0 shadow-xs"
style={{ backgroundColor: moneyColor(isProfit) }}
>
{riderInitials(name)}
</div>
<div className="min-w-0">
<p className="text-xs font-bold text-slate-900 truncate">{name}</p>
<p className="text-[11px] text-slate-500">
{orderCount} order{orderCount !== 1 ? 's' : ''} · {kms.toFixed(1)} km total
</p>
</div>
</div>
<div className="flex items-center gap-6 ml-auto flex-wrap">
<div className="text-right">
<p className="text-[10.5px] font-medium text-slate-400 uppercase">Revenue</p>
<p className="text-xs font-bold text-slate-900 tabular-nums">{rupees(revenue)}</p>
</div>
<div className="text-right">
<p className="text-[10.5px] font-medium text-slate-400 uppercase">Cost</p>
<p className="text-xs font-bold text-slate-900 tabular-nums">{rupees(totalCost)}</p>
</div>
<div className="text-right min-w-[100px]">
<p className="text-[10.5px] font-medium text-slate-400 uppercase">Net</p>
<p className="text-xs font-bold tabular-nums" style={{ color: moneyColor(isProfit) }}>
{isProfit ? '+' : ''}
{rupees(net)}
</p>
<div className="w-full bg-slate-100 rounded-full h-1.5 mt-1 overflow-hidden">
<div
className="h-full rounded-full transition-all"
style={{
width: `${clamp(Math.abs(margin), 0, 100)}%`,
backgroundColor: moneyColor(isProfit)
}}
/>
</div>
</div>
<div className="text-slate-400 pl-1">
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</div>
</div>
</button>
{isExpanded && <RiderDetailPanel metrics={metrics} />}
</div>
);
}
@@ -453,15 +363,6 @@ function RiderProfitabilityCard({ rider, metrics, isExpanded, isFocused, onToggl
// 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,
@@ -476,16 +377,13 @@ export default function ProfitabilitySection({
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
}, []);
// Enrich riders with computed metrics
const enriched = useMemo(
() => riders.map((r) => ({ ...r, _m: calcRiderMetrics(r, selectedDate, batches) })),
[riders, selectedDate, batches]
);
// Filter out inactive riders (those with 0 orders in the selected date/slot) to ensure accurate KPI aggregations
const activeEnriched = useMemo(() => enriched.filter((r) => r._m.orders.length > 0), [enriched]);
// Aggregate overall slot totals (pre-filtered for BI consistency)
const slotRevenue = activeEnriched.reduce((s, r) => s + r._m.revenue, 0);
const slotCost = activeEnriched.reduce((s, r) => s + r._m.totalCost, 0);
const slotNet = activeEnriched.reduce((s, r) => s + r._m.net, 0);
@@ -496,65 +394,96 @@ export default function ProfitabilitySection({
const dailyIsProfit = totalDailyProfit >= 0;
const slotIsProfit = slotNet >= 0;
// Sorted loss-first so the riders needing attention are at the top.
const sorted = useMemo(() => [...activeEnriched].sort((a, b) => a._m.net - b._m.net), [activeEnriched]);
const kpis = [
{
title: `Daily ${dailyIsProfit ? 'profit' : 'loss'}`,
value: rupees(Math.abs(totalDailyProfit)),
icon: dailyIsProfit ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />,
color: moneyColor(dailyIsProfit)
},
{
title: 'Riders active',
value: String(activeEnriched.length),
icon: <Users className="w-4 h-4" />,
color: STATUS.info
},
{
title: 'Total distance',
value: `${slotKms.toFixed(1)} km`,
icon: <Ruler className="w-4 h-4" />,
color: STATUS.picked
},
{
title: 'Slot revenue',
value: rupees(slotRevenue),
icon: <Wallet className="w-4 h-4" />,
color: STATUS.success
},
{
title: 'Slot cost',
value: rupees(slotCost),
icon: <CreditCard className="w-4 h-4" />,
color: STATUS.error
},
{
title: 'Slot net',
value: `${slotIsProfit ? '+' : ''}${rupees(slotNet)}`,
icon: <PiggyBank className="w-4 h-4" />,
color: moneyColor(slotIsProfit)
}
];
return (
<VStack gap={3} padding={3}>
<HStack justify="between" vAlign="center" wrap="wrap" gap={2}>
<VStack gap={0.5} padding={0}>
<Heading level={2}>Profitability overview</Heading>
<Text type="supporting">
<div className="w-full h-full overflow-y-auto p-4 md:p-6 bg-slate-50 space-y-6 box-border">
{/* Section Header */}
<div className="flex items-center justify-between flex-wrap gap-2">
<div>
<h2 className="text-base font-bold text-slate-900 tracking-tight">Profitability overview</h2>
<p className="text-xs text-slate-500 font-medium mt-0.5">
{activeEnriched.length} rider{activeEnriched.length !== 1 ? 's' : ''} · {profitCount} profitable · {lossCount} at loss
</Text>
</VStack>
</HStack>
</p>
</div>
</div>
<KpiGrid minWidth={200}>
<StatCard
title={`Daily ${dailyIsProfit ? 'profit' : 'loss'}`}
value={rupees(Math.abs(totalDailyProfit))}
icon={dailyIsProfit ? <MdTrendingUp size={20} /> : <MdTrendingDown size={20} />}
color={moneyColor(dailyIsProfit)}
/>
<StatCard
title="Riders active"
value={activeEnriched.length}
icon={<MdPeopleAlt size={20} />}
color={STATUS.info}
/>
<StatCard
title="Total distance"
value={`${slotKms.toFixed(1)} km`}
icon={<MdStraighten size={20} />}
color={STATUS.picked}
/>
<StatCard
title="Slot revenue"
value={rupees(slotRevenue)}
icon={<MdAccountBalanceWallet size={20} />}
color={STATUS.success}
/>
<StatCard
title="Slot cost"
value={rupees(slotCost)}
icon={<MdPayments size={20} />}
color={STATUS.error}
/>
<StatCard
title="Slot net"
value={`${slotIsProfit ? '+' : ''}${rupees(slotNet)}`}
icon={<MdOutlineSavings size={20} />}
color={moneyColor(slotIsProfit)}
/>
</KpiGrid>
{/* KPI Cards Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-3.5">
{kpis.map((kpi) => (
<div
key={kpi.title}
className="bg-white border border-slate-200 rounded-xl p-3.5 shadow-xs flex flex-col justify-between min-h-[105px] relative overflow-hidden transition-all hover:shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<span className="text-[10.5px] font-bold uppercase tracking-wider text-slate-700">
{kpi.title}
</span>
<span
className="w-7 h-7 rounded-lg flex items-center justify-center shrink-0"
style={{ backgroundColor: `${kpi.color}14`, color: kpi.color }}
>
{kpi.icon}
</span>
</div>
<div className="text-2xl font-bold tracking-tight tabular-nums mt-2" style={{ color: kpi.color }}>
{kpi.value}
</div>
<div className="absolute left-3.5 right-3.5 bottom-2 h-0.5 rounded-full" style={{ backgroundColor: kpi.color }} />
</div>
))}
</div>
<VStack gap={2} padding={0}>
<Text weight="bold">Riders list</Text>
{/* Riders List */}
<div className="space-y-3">
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-800">Riders list</h3>
{sorted.length === 0 ? (
<EmptyState title="No riders to show" description="No riders have orders in this slot." icon={<MdPeopleAlt size={28} />} />
<div className="flex flex-col items-center justify-center p-12 bg-white rounded-xl border border-slate-200 text-center shadow-xs">
<Users className="w-8 h-8 text-slate-300 mb-2" />
<p className="text-sm font-semibold text-slate-800">No riders to show</p>
<p className="text-xs text-slate-500 mt-1">No riders have orders in this slot.</p>
</div>
) : (
<VStack gap={2} padding={0}>
<div className="space-y-2.5">
{sorted.map((rider) => (
<RiderProfitabilityCard
key={rider.id}
@@ -566,10 +495,10 @@ export default function ProfitabilitySection({
onFocus={handleRiderFocus}
/>
))}
</VStack>
</div>
)}
</VStack>
</VStack>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -4,9 +4,9 @@ import { motion } from 'framer-motion';
import dayjs from 'dayjs';
import { Ban, FileSpreadsheet, Package, Plus, Truck, Upload } from 'lucide-react';
import { Button, ConfirmModal, DataTable, EmptyState, PageHeader, SearchInput, Stack, StatusBadge, Tabs } from '@/components/ds';
import { ListToolbar } from '@/components/doormile/ListToolbar';
import { useBulkCancelBookings, useCustomers } from '@/lib/doormileHooks';
import { createAutomationDeliveries, getBookings } from '@/api/doormile';
import { createAutomationDeliveries } from '@/api/doormile';
import { drainBookings } from '@/lib/assistant/scan';
import { OpenToast, messageOf } from '@/api/doormile/notify';
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
import { ORDER_STATUS_GROUPS, statusesInGroup } from '@/lib/orderStatusGroups';
@@ -26,6 +26,10 @@ import { currency, exportRows, km as formatKm, matchesQuery, orDash, useDebounce
* and hands back a preview to reconcile before anything is committed.
*/
/* A frozen default so the `data =` fallback is referentially stable across
renders — a fresh object literal there would re-run every downstream memo. */
const EMPTY_SCAN = Object.freeze({ rows: [], truncated: false, scanned: 0, pagesFetched: 0, total: 0 });
const TABS = [
{ value: 'pending', label: 'Pending' },
{ value: 'assigned', label: 'Assigned' },
@@ -69,16 +73,37 @@ export default function Orders() {
/* Polled: an operator watching the pending queue needs new bookings to
appear without reaching for refresh. */
/* `getBookings(1, 1000)` used to back this screen. That is page ONE at the
API's hard cap, and it discards the envelope's `total`, so past 1000
lifetime bookings every tab here was computed from a partial list with no
way to know it. `/admin/bookings` documents no row order either (scan.js
detects it per call rather than assuming), so a freshly created order is
not guaranteed to be on page 1 — it could simply be absent from every tab.
Drain the pages instead, and surface `truncated` below rather than quietly
showing a short list. `cached: false` because TanStack owns freshness for
this screen; scan.js's own 20s page cache would cap the refetch interval. */
const {
data: bookings = [],
data: scan = EMPTY_SCAN,
isLoading,
isFetching,
refetch: refetchOrders
} = useQuery({
queryKey: ['doormile', 'bookings', 'bulk'],
queryFn: async () => (await getBookings(1, 1000)) || [],
queryKey: ['doormile', 'bookings', 'all'],
queryFn: () => drainBookings({ cached: false }),
refetchInterval: 15_000,
refetchOnMount: 'always'
});
const bookings = scan.rows;
const sortedBookings = useMemo(() => {
return [...(bookings || [])].sort((a, b) => {
const idA = Number(a.bookingid) || 0;
const idB = Number(b.bookingid) || 0;
return idB - idA;
});
}, [bookings]);
const customerMap = useMemo(() => {
const map = new Map();
(customers || []).forEach((customer) => {
@@ -90,7 +115,7 @@ export default function Orders() {
const counts = useMemo(() => {
const byStatus = {};
(bookings || []).forEach((booking) => {
(sortedBookings || []).forEach((booking) => {
const status = String(booking.status || '').toLowerCase();
byStatus[status] = (byStatus[status] || 0) + 1;
});
@@ -100,18 +125,18 @@ export default function Orders() {
statusesInGroup(group).reduce((sum, status) => sum + (byStatus[status] || 0), 0),
])
);
}, [bookings]);
}, [sortedBookings]);
const tabStatuses = useMemo(() => statusesInGroup(tab), [tab]);
const rows = useMemo(
() =>
(bookings || [])
(sortedBookings || [])
.filter((booking) => tabStatuses.includes(String(booking.status || '').toLowerCase()))
.filter((booking) =>
matchesQuery(booking, ['bookingno', 'pickupaddress', 'deliveryaddress', 'notes'], debouncedSearch)
),
[bookings, tabStatuses, debouncedSearch]
[sortedBookings, tabStatuses, debouncedSearch]
);
/* A row that scrolled out of the current tab must not stay silently selected
@@ -125,8 +150,6 @@ export default function Orders() {
[rows, selectedIds]
);
const refetchOrders = () => queryClient.invalidateQueries({ queryKey: ['doormile', 'bookings'] });
/* The optimiser is a separate service with its own payload contract, so the
booking fields are mapped onto the names it expects rather than passed
through. Timestamps are formatted bare: Doormile's are naive IST, and
@@ -251,7 +274,13 @@ export default function Orders() {
key: 'status',
header: 'Status',
sortable: true,
cell: (row) => <StatusBadge status={row.status} dot size="sm" />,
cell: (row) => {
const isArrived =
String(row.status || '').toLowerCase() === 'pickup_scheduled' &&
Boolean(row.reachedat ?? row.reached_at ?? row.reachedAt ?? row.reachedtime ?? row.reached_time);
const displayStatus = isArrived ? 'arrived' : row.status;
return <StatusBadge status={displayStatus} dot size="sm" />;
},
},
],
[customerMap]
@@ -287,20 +316,26 @@ export default function Orders() {
/>
<Stack space="sm">
<div className="flex items-center justify-between gap-4 w-full">
<Tabs
tabs={TABS.map((entry) => ({ ...entry, count: counts[entry.value] ?? 0 }))}
value={tab}
onChange={setTab}
className="shrink-0"
/>
{/* Both halves used to be `shrink-0` in a non-wrapping row, which came to
1049px of content — on a 375px phone the whole document scrolled
sideways rather than this strip. The row now wraps, and the tab strip
scrolls inside its own track, so every tab stays reachable. */}
<div className="flex flex-wrap items-center justify-between gap-3 w-full">
<div className="-mx-1 max-w-full min-w-0 overflow-x-auto px-1 py-0.5">
<Tabs
tabs={TABS.map((entry) => ({ ...entry, count: counts[entry.value] ?? 0 }))}
value={tab}
onChange={setTab}
className="shrink-0"
/>
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="flex min-w-0 flex-1 flex-wrap items-center justify-end gap-2 sm:flex-none">
<SearchInput
value={search}
onChange={setSearch}
placeholder="Search by order number, address or note"
className="w-72"
className="w-full sm:w-72"
/>
{selectedIds.length > 0 && tab !== 'cancelled' && (
<Button variant="outline" onClick={() => setCancelOpen(true)}>
@@ -321,6 +356,16 @@ export default function Orders() {
{rows.length === (counts[tab] ?? 0)
? `${counts[tab] ?? 0} orders`
: `${rows.length} of ${counts[tab] ?? 0} orders`}
{/* A capped scan means an order really can be missing from this tab.
Say so — a silently short list is what sent operators hunting for
an order they had just created. */}
{scan.truncated && (
<span className="text-warning">
{` — showing the first ${scan.scanned.toLocaleString('en-IN')} of ${scan.total.toLocaleString(
'en-IN'
)} bookings, so this count is a floor. Search by order number to find a specific order.`}
</span>
)}
</p>
<DataTable
@@ -332,7 +377,17 @@ export default function Orders() {
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
pageSize={25}
defaultSort={{ key: 'bookingno', direction: 'desc' }}
/* No defaultSort: `sortedBookings` above already orders these newest
first by `bookingid`, and DataTable leaves rows untouched when it
has no sort of its own.
This used to be `{ key: 'bookingno', direction: 'desc' }`, which
threw that ordering away and was the reason a just-created order
looked missing. `bookingno` is `DM-BK-<random hex>-<digits>` and
the comparator is `localeCompare`, so sorting on it orders by a
random token — a new booking landed at an arbitrary point across
eight pages of 25 rather than at the top. Clicking the Order header
still sorts that way for anyone who wants it. */
isFiltered={Boolean(debouncedSearch)}
onClearFilters={() => setSearch('')}
emptyState={

View File

@@ -9,7 +9,6 @@ import {
Input, KpiCard, Modal, PageHeader, SearchInput, Select, SelectContent, SelectItem, SelectTrigger,
SelectValue, Stack, StatusBadge, Surface, Tabs, Textarea,
} from '@/components/ds';
import { ListToolbar } from '@/components/doormile/ListToolbar';
import {
useAssignMilerVehicle, useBlockMiler, useMilerLogs, useMilers, useNotifyMiler,
useRiderSummaryCounts, useVehicles,
@@ -245,20 +244,24 @@ export default function Riders() {
</Grid>
<Stack space="sm">
<div className="flex items-center justify-between gap-4 w-full">
<Tabs
tabs={TABS.map((entry) => ({ ...entry, count: counts[entry.value] }))}
value={tab}
onChange={setTab}
className="shrink-0"
/>
{/* Wraps, and the tab strip scrolls in its own track — see Orders.jsx
for why two `shrink-0` halves scrolled the whole page on a phone. */}
<div className="flex flex-wrap items-center justify-between gap-3 w-full">
<div className="-mx-1 max-w-full min-w-0 overflow-x-auto px-1 py-0.5">
<Tabs
tabs={TABS.map((entry) => ({ ...entry, count: counts[entry.value] }))}
value={tab}
onChange={setTab}
className="shrink-0"
/>
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="flex min-w-0 flex-1 items-center justify-end gap-2 sm:flex-none">
<SearchInput
value={search}
onChange={setSearch}
placeholder="Search by name, phone, id or city"
className="w-72"
className="w-full sm:w-72"
/>
</div>
</div>