initial commit
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -41,8 +41,6 @@
|
||||
|
||||
.dispatch-container * {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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={
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user