updates on the bookings page and updated ai and more thngs

This commit is contained in:
2026-09-11 11:20:38 +05:30
parent 1caa7b71a9
commit 49ee0c5076
40 changed files with 5796 additions and 359 deletions

View File

@@ -0,0 +1,592 @@
import React, { useMemo, useState, useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { motion } from 'framer-motion';
import {
Ban, Bike, BookMarked, CheckCircle2, Clock, FileSpreadsheet,
Package, PackageSearch, RefreshCw, Sparkles, UserCheck, XCircle,
} from 'lucide-react';
import {
Alert, Button, DataTable, Drawer, EmptyState, Grid, KpiCard,
Modal, PageHeader, Stack, StatusBadge, Surface, Tabs,
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ds';
import { ListToolbar } from '@/components/doormile/ListToolbar';
import { OpenToast, messageOf } from '@/api/doormile/notify';
import { drainBookings } from '@/lib/assistant/scan';
import {
useBooking, useAssignMilerToBooking, useRiderOptions,
useCancelBooking, useBulkCancelBookings,
} from '@/lib/doormileHooks';
import { assignMilerToBooking, notifyMiler } from '@/api/doormile/endpoints';
import {
BOOKING_TABS, bookingsForTab, customerAppBookings,
quotedPrice, serviceOptionOf, serviceType,
} from '@/lib/customerAppBookings';
import { currency, exportRows, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
import { pickupSourceTypeLabel } from '@/lib/orderFlow';
/**
* Bookings — customer app (doormile_cx) bookings management with tab separation
* (Created, Rider Assigned, Delivered, Cancelled) and bulk rider assignment.
*/
const EXPORT_COLUMNS = [
{ key: 'bookingno', header: 'Booking' },
{ key: 'status', header: 'Status' },
{ key: 'pickupaddress', header: 'Pickup' },
{ key: 'pickuppincode', header: 'Pickup pincode' },
{ key: 'deliveryaddress', header: 'Drop' },
{ key: 'deliverypincode', header: 'Drop pincode' },
{ key: 'service', header: 'Service', value: (row) => serviceType(row) },
{ key: 'price', header: 'Quoted', value: (row) => quotedPrice(row) },
{ key: 'createdat', header: 'Booked' },
];
/** One booking in full. Fetched on open rather than up front. */
function BookingDrawer({ bookingId, onClose }) {
const { data: booking, isLoading, refetch: refetchBooking } = useBooking(bookingId);
const { data: riders = [] } = useRiderOptions();
const assignMutation = useAssignMilerToBooking();
const [selectedRiderId, setSelectedRiderId] = useState('');
const parcels = booking?.parcels || [];
const payments = booking?.payments || [];
const price = serviceOptionOf(booking);
const assignedUserId = booking?.assignedmileruserid || booking?.mileruserid;
const assignedRider = useMemo(() => {
if (!assignedUserId) return null;
return riders.find((r) => String(r.userid || r.mileruserid || r.id) === String(assignedUserId));
}, [assignedUserId, riders]);
const handleAssign = async () => {
if (!selectedRiderId || !bookingId) return;
const target = riders.find((r) => String(r.userid || r.mileruserid || r.id) === String(selectedRiderId));
const targetUserId = target?.userid || target?.mileruserid || target?.id || selectedRiderId;
assignMutation.mutate(
{ id: bookingId, mileruserid: Number(targetUserId) },
{
onSuccess: () => {
setSelectedRiderId('');
refetchBooking();
},
}
);
};
const isAssignable = booking?.status !== 'Cancelled' && booking?.status !== 'Delivered';
return (
<Drawer
open={Boolean(bookingId)}
onOpenChange={(next) => !next && onClose()}
title={booking?.bookingno ? `Booking ${booking.bookingno}` : 'Booking'}
icon={PackageSearch}
size="lg"
>
{isLoading ? (
<p className="text-body-sm text-ink-3">Loading booking…</p>
) : !booking ? (
<p className="text-body-sm text-ink-3">This booking could not be loaded.</p>
) : (
<Stack space="default">
<Surface variant="subtle" padding="sm" radius="lg">
<dl className="grid grid-cols-2 gap-3">
{[
['Status', <StatusBadge status={booking.consignmentstatus || booking.status} size="sm" key="s" />],
['Booked', formatDoormileTimestamp(booking.createdat)],
['Collected from', pickupSourceTypeLabel(booking.pickup_source_type)],
['Consignment', orDash(booking.consignmentid)],
['Assigned Rider', assignedRider?.displayname || assignedRider?.name || (assignedUserId ? `Rider #${assignedUserId}` : 'Unassigned')],
].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">{value}</dd>
</div>
))}
</dl>
</Surface>
{isAssignable && (
<Surface variant="card" padding="sm" radius="lg">
<p className="mb-2 text-overline uppercase text-ink-4">
{assignedUserId ? 'Reassign Rider' : 'Assign Rider'}
</p>
<div className="flex items-center gap-2">
<div className="flex-1">
<Select value={selectedRiderId} onValueChange={setSelectedRiderId}>
<SelectTrigger className="w-full">
<SelectValue placeholder={assignedRider?.displayname || assignedRider?.name || 'Select rider…'} />
</SelectTrigger>
<SelectContent>
{riders.map((r) => {
const id = String(r.userid || r.mileruserid || r.id);
return (
<SelectItem key={id} value={id}>
{r.label || r.displayname || r.name || `Rider #${id}`}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<Button
variant="primary"
size="sm"
onClick={handleAssign}
loading={assignMutation.isPending}
disabled={!selectedRiderId || assignMutation.isPending}
>
Assign
</Button>
</div>
</Surface>
)}
<div>
<p className="mb-2 text-overline uppercase text-ink-4">Pickup</p>
<p className="text-body-sm text-ink-2">{orDash(booking.pickupaddress)}</p>
<p className="text-caption text-ink-3">{orDash(booking.pickuppincode)}</p>
</div>
<div>
<p className="mb-2 text-overline uppercase text-ink-4">Drop</p>
<p className="text-body-sm text-ink-2">{orDash(booking.deliveryaddress)}</p>
<p className="text-caption text-ink-3">{orDash(booking.deliverypincode)}</p>
</div>
<div>
<p className="mb-2 text-overline uppercase text-ink-4">Price</p>
{!price ? (
<p className="text-body-sm text-ink-3">No price was quoted for this booking.</p>
) : (
<Surface variant="subtle" padding="sm" radius="lg">
<dl className="grid grid-cols-2 gap-3">
{[
['Service', orDash(price.servicetype)],
['Quoted', currency(price.estimatedprice)],
['Expected delivery', formatDoormileTimestamp(price.estimateddeliveryat)],
['SLA due', formatDoormileTimestamp(price.sladueat)],
].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">{value}</dd>
</div>
))}
</dl>
</Surface>
)}
</div>
<div>
<p className="mb-2 text-overline uppercase text-ink-4">
Parcels {parcels.length ? `· ${parcels.length}` : ''}
</p>
{parcels.length === 0 ? (
<p className="text-body-sm text-ink-3">No parcel detail recorded.</p>
) : (
<Stack space="xs">
{parcels.map((parcel, i) => (
<Surface key={i} variant="subtle" padding="sm" radius="lg">
<p className="text-body-sm font-medium text-ink-1">{orDash(parcel.itemcategory)}</p>
<p className="text-caption text-ink-3">
{orDash(parcel.itemdescription)}
{parcel.weight ? ` · ${parcel.weight} kg` : ''}
{parcel.declaredvalue ? ` · ${currency(parcel.declaredvalue)}` : ''}
</p>
</Surface>
))}
</Stack>
)}
</div>
{payments.length > 0 && (
<div>
<p className="mb-2 text-overline uppercase text-ink-4">Payments</p>
<Stack space="xs">
{payments.map((p, i) => (
<Surface key={i} variant="subtle" padding="sm" radius="lg">
<p className="text-body-sm text-ink-1">
{currency(p.amount)} · {orDash(p.paymentmode)}
</p>
<p className="text-caption text-ink-3">{orDash(p.paymentstatus)}</p>
</Surface>
))}
</Stack>
</div>
)}
</Stack>
)}
</Drawer>
);
}
export default function Bookings() {
const [tab, setTab] = useState('all');
const [search, setSearch] = useState('');
const [openId, setOpenId] = useState(null);
const [selectedIds, setSelectedIds] = useState([]);
const [manualAssignOpen, setManualAssignOpen] = useState(false);
const [cancelOpen, setCancelOpen] = useState(false);
const [selectedRiderId, setSelectedRiderId] = useState('');
const [isAssigning, setIsAssigning] = useState(false);
const [isCancelling, setIsCancelling] = useState(false);
const queryClient = useQueryClient();
const debouncedSearch = useDebouncedValue(search, 250);
const { data: riders = [] } = useRiderOptions();
const { data: scan, isLoading, isFetching, isError, error, refetch } = useQuery({
queryKey: ['doormile', 'bookings', 'all'],
queryFn: () => drainBookings({ cached: false }),
refetchInterval: 15_000,
refetchOnMount: 'always',
});
const bookings = useMemo(() => customerAppBookings(scan?.rows), [scan]);
// Tab counts
const counts = useMemo(() => {
return {
all: bookings.length,
created: bookingsForTab(bookings, 'created').length,
assigned: bookingsForTab(bookings, 'assigned').length,
delivered: bookingsForTab(bookings, 'delivered').length,
cancelled: bookingsForTab(bookings, 'cancelled').length,
};
}, [bookings]);
const rows = useMemo(
() =>
bookingsForTab(bookings, tab).filter((b) =>
matchesQuery(b, ['bookingno', 'pickupaddress', 'deliveryaddress', 'status'], debouncedSearch)
),
[bookings, tab, debouncedSearch]
);
const selectedOrders = useMemo(
() => rows.filter((row) => selectedIds.includes(row.bookingid)),
[rows, selectedIds]
);
// Clear selection when tab changes
useEffect(() => {
setSelectedIds([]);
}, [tab]);
const stats = useMemo(() => {
const created = counts.created;
const value = bookings.reduce((sum, b) => sum + quotedPrice(b), 0);
return { total: bookings.length, created, value };
}, [bookings, counts]);
const riderLookup = useMemo(() => {
const map = new Map();
riders.forEach((r) => {
const id = String(r.userid || r.mileruserid || r.id);
map.set(id, r.displayname || r.name || r.label);
});
return map;
}, [riders]);
// Bulk manual assign handler
const handleBulkManualAssign = async () => {
if (!selectedRiderId || !selectedIds.length) {
OpenToast('Please select a rider first', 'warning');
return;
}
const targetRider = riders.find((r) => String(r.userid || r.mileruserid || r.id) === String(selectedRiderId));
if (!targetRider) {
OpenToast('Please select a valid rider', 'warning');
return;
}
const riderUserId = targetRider.userid || targetRider.mileruserid || targetRider.id;
setIsAssigning(true);
try {
await Promise.all(
selectedIds.map((bookingId) =>
assignMilerToBooking(bookingId, { mileruserid: Number(riderUserId) })
)
);
if (targetRider.milerprofileid) {
notifyMiler(
targetRider.milerprofileid,
'DoormileXpress',
`${selectedIds.length} customer booking(s) assigned to you`
).catch(() => {});
}
OpenToast(
`${selectedIds.length} booking${selectedIds.length === 1 ? '' : 's'} assigned to ${targetRider.displayname || targetRider.name || 'rider'}`,
'success'
);
queryClient.invalidateQueries({ queryKey: ['doormile', 'bookings'] });
refetch();
setSelectedIds([]);
setManualAssignOpen(false);
setSelectedRiderId('');
} catch (err) {
OpenToast(messageOf(err, 'Failed to assign orders'), 'error', 4000);
} finally {
setIsAssigning(false);
}
};
// Bulk cancel handler
const handleBulkCancel = async () => {
if (!selectedIds.length) return;
setIsCancelling(true);
try {
const { bulkCancelBookings } = await import('@/api/doormile/endpoints');
await bulkCancelBookings(selectedIds);
OpenToast(`${selectedIds.length} booking${selectedIds.length === 1 ? '' : 's'} cancelled`, 'success');
queryClient.invalidateQueries({ queryKey: ['doormile', 'bookings'] });
refetch();
setSelectedIds([]);
setCancelOpen(false);
} catch (err) {
OpenToast(messageOf(err, 'Failed to cancel orders'), 'error', 4000);
} finally {
setIsCancelling(false);
}
};
const columns = useMemo(
() => [
{
key: 'booking',
header: 'Booking',
sortable: true,
accessor: (row) => (
<div>
<p className="text-body-sm font-semibold text-ink-1">{orDash(row.bookingno)}</p>
<p className="text-caption text-ink-3">{formatDoormileTimestamp(row.createdat)}</p>
</div>
),
},
{
key: 'route',
header: 'Route',
accessor: (row) => (
<div className="max-w-xs">
<p className="truncate text-body-sm text-ink-2" title={row.pickupaddress || ''}>
{orDash(row.pickupaddress)}
</p>
<p className="truncate text-caption text-ink-3" title={row.deliveryaddress || ''}>
→ {orDash(row.deliveryaddress)}
</p>
</div>
),
},
{
key: 'rider',
header: 'Rider',
hideBelow: 'md',
accessor: (row) => {
const riderId = String(row.assignedmileruserid || row.mileruserid || '');
const name = riderLookup.get(riderId);
if (name) {
return <span className="text-body-sm font-medium text-ink-1">{name}</span>;
}
if (riderId) {
return <span className="text-caption text-ink-2">Rider #{riderId}</span>;
}
return <span className="text-caption text-ink-4">Unassigned</span>;
},
},
{ key: 'service', header: 'Service', hideBelow: 'md', accessor: (row) => orDash(serviceType(row)) },
{
key: 'price',
header: 'Price',
sortable: true,
accessor: (row) => (quotedPrice(row) ? currency(quotedPrice(row)) : '—'),
},
{
key: 'status',
header: 'Status',
accessor: (row) => <StatusBadge status={row.consignmentstatus || row.status} size="sm" />,
},
],
[riderLookup]
);
const canAssign = tab === 'created' && selectedOrders.length > 0;
return (
<Stack space="lg">
<PageHeader
title="Bookings"
subtitle="Booked by customers in the app — the counterpart to Orders, which this console creates."
icon={BookMarked}
actions={
<Button variant="outline" onClick={() => refetch()} loading={isFetching}>
<RefreshCw className="mr-1.5 h-4 w-4" /> Refresh
</Button>
}
/>
{isError && <Alert tone="danger">{messageOf(error, 'Could not load bookings')}</Alert>}
{scan?.truncated && (
<Alert tone="warning">
Showing the most recent {Number(scan.scanned || 0).toLocaleString('en-IN')} of{' '}
{Number(scan.total || 0).toLocaleString('en-IN')} bookings. The counts below are a
floor, not a total.
</Alert>
)}
<Grid cols={3} gap="default">
<KpiCard label="Customer bookings" value={stats.total} />
<KpiCard label="Created (Awaiting Rider)" value={stats.created} />
<KpiCard label="Total quoted" value={currency(stats.value)} />
</Grid>
<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={BOOKING_TABS.map((t) => ({
value: t.value,
label: t.label,
count: counts[t.value] ?? 0,
}))}
value={tab}
onChange={setTab}
className="shrink-0"
/>
</div>
<div className="flex min-w-0 flex-1 flex-wrap items-center justify-end gap-2 sm:flex-none">
<ListToolbar
search={search}
onSearchChange={setSearch}
placeholder="Search booking no, address or status"
onExport={() => exportRows(rows, EXPORT_COLUMNS, `customer-bookings-${tab}`)}
exportDisabled={rows.length === 0}
/>
</div>
</div>
<DataTable
columns={columns}
rows={rows}
getRowId={(row) => row.bookingid}
loading={isLoading}
refreshing={isFetching && !isLoading}
selectedIds={tab === 'created' ? selectedIds : undefined}
onSelectionChange={tab === 'created' ? setSelectedIds : undefined}
onRowClick={(row) => setOpenId(row.bookingid)}
pageSize={25}
isFiltered={Boolean(debouncedSearch)}
onClearFilters={() => setSearch('')}
emptyState={
debouncedSearch ? undefined : (
<EmptyState
icon={Package}
title={`No ${BOOKING_TABS.find((entry) => entry.value === tab)?.label.toLowerCase()} bookings`}
description="Customer app bookings will appear here as they are placed."
/>
)
}
/>
{/* Floating Bulk Action Bar */}
{canAssign && (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="fixed bottom-6 right-6 z-50 flex flex-wrap items-center gap-2.5 rounded-2xl border border-slate-200 bg-white/95 p-2.5 shadow-2xl backdrop-blur-md"
>
<div className="px-2 text-xs font-bold text-slate-700">
{selectedOrders.length} selected
</div>
<Button
variant="default"
className="bg-emerald-600 hover:bg-emerald-700 text-white shadow-xs"
onClick={() => setManualAssignOpen(true)}
>
<Bike className="mr-1.5 h-4 w-4" /> Assign Rider ({selectedOrders.length})
</Button>
<Button
variant="ghost"
className="text-red-600 hover:bg-red-50 hover:text-red-700"
onClick={() => setCancelOpen(true)}
>
<Ban className="mr-1.5 h-4 w-4" /> Cancel
</Button>
</motion.div>
)}
{/* Bulk Assign Modal */}
<Modal
open={manualAssignOpen}
onOpenChange={setManualAssignOpen}
title={`Assign Rider to ${selectedIds.length} Booking${selectedIds.length === 1 ? '' : 's'}`}
description="Choose an active rider to assign the selected customer bookings directly."
>
<div className="space-y-4 pt-2">
<div>
<label className="block text-xs font-semibold text-slate-800 mb-1.5">
Select Rider <span className="text-red-500">*</span>
</label>
<select
value={selectedRiderId}
onChange={(e) => setSelectedRiderId(e.target.value)}
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-2xs focus:border-emerald-500 focus:outline-hidden focus:ring-2 focus:ring-emerald-500/20"
>
<option value="">-- Choose Rider --</option>
{riders.map((r) => {
const id = String(r.userid || r.mileruserid || r.id);
return (
<option key={id} value={id}>
{r.displayname || r.name || `Rider #${id}`} {r.phone ? `(${r.phone})` : ''}
</option>
);
})}
</select>
</div>
<div className="flex justify-end gap-2 pt-3 border-t border-slate-100">
<Button variant="outline" onClick={() => setManualAssignOpen(false)} disabled={isAssigning}>
Close
</Button>
<Button
className="bg-emerald-600 hover:bg-emerald-700 text-white"
onClick={handleBulkManualAssign}
loading={isAssigning}
disabled={!selectedRiderId || isAssigning}
>
Confirm & Assign
</Button>
</div>
</div>
</Modal>
{/* Cancel Confirmation Modal */}
<Modal
open={cancelOpen}
onOpenChange={setCancelOpen}
title={`Cancel ${selectedIds.length} Customer Booking${selectedIds.length === 1 ? '' : 's'}`}
description="Are you sure you want to cancel the selected bookings? This action cannot be undone."
>
<div className="flex justify-end gap-2 pt-4">
<Button variant="outline" onClick={() => setCancelOpen(false)} disabled={isCancelling}>
Go back
</Button>
<Button variant="destructive" onClick={handleBulkCancel} loading={isCancelling}>
Cancel Bookings
</Button>
</div>
</Modal>
<BookingDrawer bookingId={openId} onClose={() => setOpenId(null)} />
</Stack>
);
}

View File

@@ -16,6 +16,8 @@ import { useZone } from '@/lib/ZoneContext';
import { BATCHES, batchRangeLabel, getRowBatchId } from '@/lib/batchBucket';
import { formatDoormileTimestamp, parseDoormileTimestamp } from '@/lib/doormileTimestamp';
import { currency, exportRows, km as formatKm, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
import { pickupSourceTypeLabel } from '@/lib/orderFlow';
import { summariseRouting } from '@/lib/routingSummary';
/**
* Deliveries — orders that have moved past merely being created.
@@ -640,6 +642,59 @@ export default function Deliveries() {
);
}
/**
* How this booking was routed, and on what.
*
* Backend decides the route; this only reports the decision and the inputs it
* was made from, so support can answer "why does the rider's screen say hand
* over at a base instead of deliver?" without reconstructing a pincode rule by
* hand.
*
* Before pickup the decision has not been taken — `decided` is false and what is
* shown is a projection from the captured from/to, labelled as such. A number
* presented as a decision when it is only a forecast is worse than no number.
*
* Wording note: the wire says hub, operators and riders say base. The
* translation happens here, at the edge, and never travels back up.
*/
function RoutingSummary({ routing }) {
const s = summariseRouting(routing);
if (!s) return null;
return (
<div>
<p className="mb-2 text-overline uppercase text-ink-4">Routing</p>
<Surface variant="subtle" padding="sm" radius="lg">
<Stack space="xs">
<p className="text-body-sm text-ink-1">{s.reason}</p>
<p className="text-caption text-ink-3">{s.route}</p>
<dl className="grid grid-cols-2 gap-3 pt-1">
{[
['Collected from', pickupSourceTypeLabel(routing.pickup_source_type)],
['Parcel state', s.consignmentState],
['Next step', s.nextAction],
['Base', s.base || '—'],
].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">{value}</dd>
</div>
))}
</dl>
{s.baseAddress ? <p className="text-caption text-ink-3">{s.baseAddress}</p> : null}
{s.inwardedAt ? (
<p className="text-caption text-ink-3">
Received at the base {formatDoormileTimestamp(s.inwardedAt)}
</p>
) : null}
</Stack>
</Surface>
</div>
);
}
/**
* What is actually in the parcel.
*
@@ -687,14 +742,40 @@ function OrderDetailDrawer({ row, onClose }) {
<div>
<p className="mb-2 text-overline uppercase text-ink-4">Pickup</p>
<p className="text-body-sm text-ink-2">{orDash(row?.pickupaddress)}</p>
<p className="text-body-sm text-ink-2">
{orDash(booking?.routing?.from_address || row?.pickupaddress)}
</p>
{booking?.routing ? (
<p className="text-caption text-ink-3">
{[
pickupSourceTypeLabel(booking.routing.pickup_source_type),
booking.routing.pickup_source_name,
booking.routing.from_pincode
]
.filter(Boolean)
.join(' · ')}
</p>
) : null}
</div>
<div>
<p className="mb-2 text-overline uppercase text-ink-4">Drop</p>
<p className="text-body-sm text-ink-2">{orDash(row?.deliveryaddress)}</p>
<p className="text-body-sm text-ink-2">
{orDash(booking?.routing?.to_address || row?.deliveryaddress)}
</p>
{booking?.routing?.destination_pincode ? (
<p className="text-caption text-ink-3">{booking.routing.destination_pincode}</p>
) : null}
</div>
{/* Why this parcel is going where it is going.
A rider rings asking why his screen says hand over at a base
instead of deliver to the customer; without this, answering meant
reconstructing the routing rule from two pincodes by hand. The
backend decides the route and reports it here with the inputs it
used, so the answer is a lookup. */}
{booking?.routing ? <RoutingSummary routing={booking.routing} /> : null}
<div>
<p className="mb-2 text-overline uppercase text-ink-4">
Parcels {parcels.length ? `· ${parcels.length}` : ''}

View File

@@ -2886,6 +2886,30 @@
letter-spacing: 0.05em;
}
/* Flow chips. The class is built from the raw flow name
(`type-${ordertype.toLowerCase()}`), so these four selectors track FLOW in
lib/orderFlow.js. `type-forward` has no rule on purpose: an ordinary hub run
never renders a chip at all (see flowChipLabel), and a rule for it would only
fire if that ever changed. The colours match their map markers — violet is a
doorstep collection in both places. */
.dispatch-container .zone-order-chip.type-reverse {
background: rgba(139, 92, 246, 0.1);
border-color: rgba(139, 92, 246, 0.3);
color: #7c3aed;
}
.dispatch-container .zone-order-chip.type-p2p {
background: rgba(14, 165, 233, 0.1);
border-color: rgba(14, 165, 233, 0.3);
color: #0284c7;
}
.dispatch-container .zone-order-chip.type-linehaul {
background: rgba(100, 116, 139, 0.12);
border-color: rgba(100, 116, 139, 0.3);
color: #475569;
}
.dispatch-container .kitchen-transition {
padding: 12px;
background: var(--kitchen-soft);
@@ -4620,6 +4644,16 @@
box-shadow: 0 0 20px rgba(245, 158, 11, 0.8), 0 0 40px rgba(245, 158, 11, 0.4);
}
/* A doorstep collection is not a kitchen. Same shape and size — it is still a
collection point and still groups a rider's stops — but violet rather than
amber, so a dispatcher scanning the map can tell "load here, this is ours"
from "go out and collect here" without opening the pin. The box-shadow is
pinned inline by createKitchenIcon() (it carries the matching violet), which
is why only the fill is set here. */
.dispatch-container .kitchen-mark.is-customer-pickup {
background: #8b5cf6;
}
/* Focused kitchen marker — larger, brighter, with a pulsing halo so users
never lose sight of the kitchen they drilled into. */
.dispatch-container .kitchen-mark.is-focused {

View File

@@ -61,6 +61,7 @@ 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';
import { FLOW } from '@/lib/orderFlow';
import {
onActivate,
getStatusStyle,
@@ -156,6 +157,38 @@ const hasValidPickup = (o) => Number.isFinite(toNum(pickupLat(o))) && Number.isF
// the same order under two different keys depending on which code path ran).
const pickupName = (o) => o.pickupcustomer || o.kitchen_key || o.locationname || o.pickuplocation || o.pickupsuburb || null;
// The STABLE identity of an order's collection point — what everything below
// groups on. The display NAME is not that identity, for two reasons:
//
// • Two different facilities can carry the same name, and grouping by name
// silently merges them into one pin.
// • A customer pickup has no facility name at all. Every customer pickup for
// one client used to resolve to the same string (the tenant's name, see
// queries.js) and therefore to ONE map pin, placed at whichever of those
// doorsteps happened to sort first. Ten collections across the city drew
// one marker.
//
// queries.js supplies `pickuppointkey` — one key per facility, one key per
// BOOKING for a customer pickup. The name fallback is kept for order objects
// that never went through that normaliser (the optimiser preview builds its
// own rows), so those behave exactly as they did before.
const pickupPointKey = (o) =>
o.pickuppointkey || (pickupName(o) || 'Unknown').toLowerCase().trim();
// Does this order require the rider to travel out to a non-facility address to
// collect? Read from the classification queries.js already did; falls back to
// false so an un-normalised row routes the way it always has.
const isCustomerPickup = (o) => o.pickupsource === 'customer';
// Text for the flow chip on an order card, or null for no chip.
//
// Deliberately silent on a plain hub run. That is the overwhelming majority of
// every board, and a chip on every card is a chip on none — the point of the
// badge is that a doorstep collection or a point-to-point job stands out from
// the wall of ordinary outbound work around it.
const flowChipLabel = (o) =>
o.ordertype && o.ordertype !== FLOW.FORWARD ? o.ordertypelabel || o.ordertype : null;
// Named delivery batches — operator's mental model of the day's waves.
// Each entry covers a half-open range [startHour, endHour) measured in
// FRACTIONAL hours (e.g. 12.5 = 12:30). Half-hour boundaries are supported.
@@ -403,19 +436,74 @@ const POPUP_TIMELINE = [
{ key: 'deliverytime', label: 'Delivered', final: true }
];
// Build a polyline-ready point list for a sorted trip:
// - drop NaN drops
// - prepend the first valid pickup we can find (so the line starts at the kitchen)
const buildTripPoints = (sorted) => {
const valid = sorted.filter(hasValidDrop);
if (!valid.length) return [];
const pickupSrc = sorted.find(hasValidPickup);
const pts = [];
if (pickupSrc) pts.push([toNum(pickupLat(pickupSrc)), toNum(pickupLon(pickupSrc))]);
valid.forEach((o) => pts.push([toNum(o.droplat || o.deliverylat), toNum(o.droplon || o.deliverylong)]));
return pts;
// Two points close enough to be the same physical visit. A hub pickup copies
// the facility's coordinates onto every booking, so consecutive stops off one
// kitchen are bit-identical; the tolerance covers a re-pinned gate.
const TRIP_POINT_MERGE_M = 50;
const isSameTripPoint = (a, b) => {
if (!a || !b) return false;
const R = 6371000;
const toRad = (d) => (d * Math.PI) / 180;
const dLat = toRad(b[0] - a[0]);
const dLon = toRad(b[1] - a[1]);
const h =
Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a[0])) * Math.cos(toRad(b[0])) * Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h)) <= TRIP_POINT_MERGE_M;
};
// Build a polyline-ready point list for a sorted trip, as the sequence of
// physical visits the rider actually makes.
//
// This used to be [one pickup, drop, drop, drop, …] — find the FIRST order with
// usable pickup coordinates, put it at the front, then chain every drop. That
// is only true when every stop is collected from the same place, which is true
// for a hub run and false for everything else. A customer pickup collects at a
// doorstep, so its collection point is a real stop on the route; drawing the
// rider's line straight from the kitchen to the delivery address skipped the
// entire first-mile leg and under-reported the distance it covers.
//
// The rule now: a collection point is emitted when the rider actually travels
// to it — always for a customer pickup (each doorstep is its own visit), and
// for a facility only when the rider is not already loaded there (first stop,
// or a switch to a different kitchen, which is the transition the sidebar
// already marks). Consecutive coincident points are merged.
//
// Returns the drop's index in `points` alongside, because callers colour
// per-step segments and previously had to INFER whether a pickup was prepended
// by comparing array lengths (`points.length > drops.length`). That inference
// held only while at most one pickup could ever appear.
const buildTripLegs = (sorted) => {
const valid = (sorted || []).filter(hasValidDrop);
const points = [];
const dropIndices = [];
let loadedAtKey = null;
const push = (pt) => {
const last = points[points.length - 1];
if (isSameTripPoint(last, pt)) return points.length - 1;
points.push(pt);
return points.length - 1;
};
valid.forEach((o) => {
if (hasValidPickup(o)) {
const key = pickupPointKey(o);
if (isCustomerPickup(o) || key !== loadedAtKey) {
push([toNum(pickupLat(o)), toNum(pickupLon(o))]);
}
// A doorstep collection never leaves the rider "loaded" anywhere they can
// collect again, so it must not suppress the next facility visit.
loadedAtKey = isCustomerPickup(o) ? null : key;
}
dropIndices.push(push([toNum(o.droplat || o.deliverylat), toNum(o.droplon || o.deliverylong)]));
});
return { points, dropIndices, orders: valid };
};
const buildTripPoints = (sorted) => buildTripLegs(sorted).points;
const getTripCacheKey = (riderId, tripKey, points) => {
if (!points || points.length === 0) return `${riderId}-${tripKey}`;
const pointsSig = points.map((p) => `${p[0].toFixed(5)},${p[1].toFixed(5)}`).join('|');
@@ -1789,10 +1877,13 @@ const Dispatch = ({
}
});
// Collection points on the map. Keyed by pickupPointKey, NOT by display
// name: a customer pickup gets its own key per booking, so ten doorstep
// collections draw ten pins instead of one pin at an arbitrary one of them.
const kitchenMap = {};
orders.forEach((o) => {
const name = pickupName(o) || 'Unknown';
const key = name.toLowerCase().trim();
const key = pickupPointKey(o);
if (!kitchenMap[key]) {
kitchenMap[key] = {
id: key,
@@ -1800,10 +1891,16 @@ const Dispatch = ({
lat: toNum(pickupLat(o)),
lon: toNum(pickupLon(o)),
orders: [],
riders: new Set()
riders: new Set(),
// Drives the marker: a facility and a doorstep collection are not the
// same kind of place and must not read as the same kind of pin.
isCustomerPickup: isCustomerPickup(o),
flow: o.ordertype || null
};
} else if (!Number.isFinite(kitchenMap[key].lat) && hasValidPickup(o)) {
// Upgrade to first valid pickup coords we see for this kitchen
// Upgrade to first valid pickup coords we see for this kitchen. Safe
// now in a way it was not before: sharing a key means genuinely being
// the same facility, rather than merely sharing a tenant.
kitchenMap[key].lat = toNum(pickupLat(o));
kitchenMap[key].lon = toNum(pickupLon(o));
}
@@ -2611,7 +2708,7 @@ const Dispatch = ({
Object.entries(trips).forEach(([tNum, tOrders]) => {
// Filter orders by focused kitchen if active
const filteredTOrders = focusedKitchen
? tOrders.filter((o) => (pickupName(o) || 'Unknown').toLowerCase().trim() === focusedKitchen.id)
? tOrders.filter((o) => pickupPointKey(o) === focusedKitchen.id)
: tOrders;
if (filteredTOrders.length === 0) return;
@@ -2626,7 +2723,8 @@ const Dispatch = ({
const sorted = [...filteredTOrders].sort((a, b) => (a.step || 0) - (b.step || 0));
// Aerial fallback — NaN-safe build
const aerialPath = buildTripPoints(sorted);
const aerialLegs = buildTripLegs(sorted);
const aerialPath = aerialLegs.points;
const isKitchenAerial = viewMode === 'kitchens' || focusedKitchen;
const path = roadPath || aerialPath;
@@ -2645,15 +2743,20 @@ const Dispatch = ({
const stepSegs = roadPath
? splitPolylineByDrops(roadPath, dropCoords)
: (() => {
const hasPickup = aerialPath.length > dropCoords.length;
// Each step's segment runs from wherever the rider was after the
// previous drop to this drop — including any collection point
// inserted in between, which is the whole first-mile leg on a
// customer pickup. Read from the indices buildTripLegs returns;
// this was previously inferred from `path.length > drops.length`,
// which silently assumed at most one pickup existed in the path.
const out = [];
for (let i = 0; i < dropCoords.length; i++) {
const a = hasPickup ? i : i - 1;
const b = hasPickup ? i + 1 : i;
if (a < 0 || a >= aerialPath.length || b >= aerialPath.length) {
const b = aerialLegs.dropIndices[i];
const a = i === 0 ? 0 : aerialLegs.dropIndices[i - 1];
if (b == null || a == null || b >= aerialPath.length || a >= b) {
out.push([]);
} else {
out.push([aerialPath[a], aerialPath[b]]);
out.push(aerialPath.slice(a, b + 1));
}
}
return out;
@@ -2771,21 +2874,28 @@ const Dispatch = ({
const KITCHEN_ICON_SIZE = 31;
const KITCHEN_ICON_FOCUSED_SIZE = 38;
const createKitchenIcon = (name, focused = false) => {
// Collection-point marker. `customerPickup` swaps the amber facility glow for
// a violet one and the initial for an arrow, because a doorstep collection
// and a kitchen are not the same kind of place: a dispatcher scanning the map
// has to be able to tell "load here, it is ours" from "go and collect here"
// without opening the pin.
const createKitchenIcon = (name, focused = false, customerPickup = false) => {
const size = focused ? KITCHEN_ICON_FOCUSED_SIZE : KITCHEN_ICON_SIZE;
const anchor = Math.round(size / 2);
const border = focused ? 3 : 2;
const glow = 14;
const rgb = customerPickup ? '139,92,246' : '245,158,11';
const glyph = customerPickup ? '&uarr;' : (name || 'K').charAt(0).toUpperCase();
return L.divIcon({
className: '',
iconSize: [size, size],
iconAnchor: [anchor, anchor],
popupAnchor: [0, -(anchor + 2)],
html: `<div class="kitchen-mark${focused ? ' is-focused' : ''}" style="width:${size}px;height:${size}px;font-size:${Math.round(
html: `<div class="kitchen-mark${focused ? ' is-focused' : ''}${
customerPickup ? ' is-customer-pickup' : ''
}" style="width:${size}px;height:${size}px;font-size:${Math.round(
size * 0.38
)}px;border-width:${border}px;box-shadow:0 0 ${glow}px rgba(245,158,11,0.8), 0 0 ${glow * 2}px rgba(245,158,11,0.4)">${(name || 'K')
.charAt(0)
.toUpperCase()}</div>`
)}px;border-width:${border}px;box-shadow:0 0 ${glow}px rgba(${rgb},0.8), 0 0 ${glow * 2}px rgba(${rgb},0.4)">${glyph}</div>`
});
};
@@ -3239,7 +3349,7 @@ const Dispatch = ({
Object.entries(trips).forEach(([tNum, tOrders]) => {
// Filter orders by focused kitchen if active
const filteredTOrders = focusedKitchen
? tOrders.filter((o) => (pickupName(o) || 'Unknown').toLowerCase().trim() === focusedKitchen.id)
? tOrders.filter((o) => pickupPointKey(o) === focusedKitchen.id)
: tOrders;
if (filteredTOrders.length === 0) return;
@@ -3293,7 +3403,8 @@ const Dispatch = ({
// line is never invisible while OSRM is in flight.
if (!isAllActiveView && !hasRoad && !failed) return;
const finalPoints = hasRoad ? roadToUse : isAllActiveView ? activeStraightLeg : buildTripPoints(sorted);
const plannedLegs = buildTripLegs(sorted);
const finalPoints = hasRoad ? roadToUse : isAllActiveView ? activeStraightLeg : plannedLegs.points;
if (!finalPoints || finalPoints.length < 2) return;
const isKitchenView = viewMode === 'kitchens' || focusedKitchen;
@@ -3321,22 +3432,24 @@ const Dispatch = ({
const validDrops = sorted.filter(hasValidDrop);
const dropCoords = validDrops.map((o) => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]);
// hasRoad: split the OSRM polyline at each drop's nearest index.
// !hasRoad: finalPoints is [pickup?, drop1, drop2, ...] — buildTripPoints
// only prepends the pickup when one's available. Detect that and
// align segment[i] with validDrops[i] either way.
// !hasRoad: align segment[i] with validDrops[i] using the drop indices
// buildTripLegs returns. This was previously inferred from
// `finalPoints.length > dropCoords.length`, which assumed the path
// held at most one pickup — true for a pure hub run, wrong as soon as
// a trip carries doorstep collections, where every extra collection
// point shifted every later segment onto the wrong step's colour.
let segments;
if (hasRoad) {
segments = splitPolylineByDrops(finalPoints, dropCoords);
} else {
const hasPickup = finalPoints.length > dropCoords.length;
segments = [];
for (let i = 0; i < dropCoords.length; i++) {
const idxA = hasPickup ? i : i - 1;
const idxB = hasPickup ? i + 1 : i;
if (idxA < 0 || idxA >= finalPoints.length || idxB >= finalPoints.length) {
const idxB = plannedLegs.dropIndices[i];
const idxA = i === 0 ? 0 : plannedLegs.dropIndices[i - 1];
if (idxB == null || idxA == null || idxB >= finalPoints.length || idxA >= idxB) {
segments.push([]);
} else {
segments.push([finalPoints[idxA], finalPoints[idxB]]);
segments.push(finalPoints.slice(idxA, idxB + 1));
}
}
}
@@ -4369,7 +4482,7 @@ const Dispatch = ({
</div>
<div className="zone-order-grid">
{displayOrders.map((o, idx) => {
const kitchenKey = (pickupName(o) || 'Unknown').toLowerCase().trim();
const kitchenKey = pickupPointKey(o);
const showTransition = prevKitchenKey !== null && kitchenKey !== prevKitchenKey;
prevKitchenKey = kitchenKey;
const isStopActive = focusedStop && focusedStop.orderid === o.orderid;
@@ -4401,7 +4514,8 @@ const Dispatch = ({
<span className="kt-ico">
<MdSwapHoriz />
</span>{' '}
Switch to <strong>{o.pickupcustomer}</strong>
{isCustomerPickup(o) ? 'Collect at' : 'Switch to'}{' '}
<strong>{o.pickupcustomer}</strong>
</div>
)}
<div
@@ -4536,11 +4650,11 @@ const Dispatch = ({
₹{parseFloat(o.deliverycharge).toFixed(0)} chg
</span>
)}
{o.ordertype && (
{flowChipLabel(o) && (
<span
className={`zone-order-chip zone-order-type type-${String(o.ordertype).toLowerCase()}`}
>
{o.ordertype}
{flowChipLabel(o)}
</span>
)}
<span className="zone-order-chip zone-order-trip">
@@ -4742,9 +4856,9 @@ const Dispatch = ({
₹{parseFloat(o.deliverycharge).toFixed(0)} chg
</span>
)}
{o.ordertype && (
{flowChipLabel(o) && (
<span className={`zone-order-chip zone-order-type type-${String(o.ordertype).toLowerCase()}`}>
{o.ordertype}
{flowChipLabel(o)}
</span>
)}
<span className="zone-order-chip zone-order-trip">
@@ -4936,9 +5050,9 @@ const Dispatch = ({
₹{parseFloat(o.deliverycharge).toFixed(0)} chg
</span>
)}
{o.ordertype && (
{flowChipLabel(o) && (
<span className={`zone-order-chip zone-order-type type-${String(o.ordertype).toLowerCase()}`}>
{o.ordertype}
{flowChipLabel(o)}
</span>
)}
<span className="zone-order-chip zone-order-trip">
@@ -5218,7 +5332,7 @@ const Dispatch = ({
<Marker
key={`k-${i}`}
position={[k.lat, k.lon]}
icon={createKitchenIcon(k.kitchenName, focusedKitchen?.id === k.id)}
icon={createKitchenIcon(k.kitchenName, focusedKitchen?.id === k.id, k.isCustomerPickup)}
zIndexOffset={focusedKitchen?.id === k.id ? 4000 : 2000}
eventHandlers={{
click: () => setFocusedKitchen(k),

View File

@@ -6,6 +6,7 @@ import {
Stack, StatusBadge,
} from '@/components/ds';
import { ListToolbar } from '@/components/doormile/ListToolbar';
import { validateHubForm } from '@/lib/hubForm';
import { useCreateHub, useDeleteHub, useHubs, useUpdateHub } from '@/lib/doormileHooks';
import { matchesQuery, orDash, percentOf, useDebouncedValue } from '@/lib/doormileFormat';
@@ -114,23 +115,19 @@ export default function Hubs() {
};
const save = () => {
const nextErrors = {};
if (!form.hubname.trim()) nextErrors.hubname = 'Give the hub a name';
if (form.applocationid === '' || Number.isNaN(Number(form.applocationid))) {
nextErrors.applocationid = 'Enter the numeric city id';
}
/* A hub is a BASE to a rider — the six fields the backend hands the rider
app to name, describe and navigate to it. validateHubForm holds why each
one is required; it lives in lib/ so the rule can be tested on its own. */
const { errors: nextErrors, isValid, values } = validateHubForm(form);
setErrors(nextErrors);
if (Object.keys(nextErrors).length) return;
if (!isValid) return;
const { hubid, ...rest } = form;
const data = {
...rest,
applocationid: Number(rest.applocationid),
/* Coordinates are optional in the form but numeric on the wire — an empty
string here is rejected, whereas 0 is accepted and simply means the hub
has not been pinned on the map yet. */
latitude: Number(rest.latitude) || 0,
longitude: Number(rest.longitude) || 0,
applocationid: values.applocationid,
latitude: values.latitude,
longitude: values.longitude,
};
const mutation = hubid ? updateHub : createHub;
@@ -352,19 +349,21 @@ export default function Hubs() {
<Input value={form.contactno} onChange={(e) => set('contactno')(e.target.value)} placeholder="9876543210" />
</Field>
<Field label="Address" className="sm:col-span-2">
<Field label="Address" required error={errors.address} className="sm:col-span-2">
<Input value={form.address} onChange={(e) => set('address')(e.target.value)} placeholder="Street, area, landmark" />
</Field>
<Field label="Pincode">
<Field label="Pincode" required error={errors.pincode}>
<Input value={form.pincode} onChange={(e) => set('pincode')(e.target.value)} placeholder="641004" />
</Field>
{/* Riders navigate to these. A hub saved without them is a base the
app can name but cannot route to. */}
<div className="grid grid-cols-2 gap-4">
<Field label="Latitude">
<Field label="Latitude" required error={errors.latitude}>
<Input value={form.latitude} onChange={(e) => set('latitude')(e.target.value)} placeholder="11.0168" />
</Field>
<Field label="Longitude">
<Field label="Longitude" required error={errors.longitude}>
<Input value={form.longitude} onChange={(e) => set('longitude')(e.target.value)} placeholder="76.9558" />
</Field>
</div>

View File

@@ -0,0 +1,194 @@
import React from 'react';
import { MapPin, Package } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { Drawer, EmptyState, LoadingState, StatusBadge } from '@/components/ds';
import { getBooking } from '@/api/doormile/endpoints';
import { currency } from '@/lib/doormileFormat';
import { plural } from '@/lib/bookingDrops';
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
/**
* The legs of one pickup.
*
* A customer-app pickup is ONE booking carrying N destinations. The list can
* only say how many (`destinationcount`); the array itself is fetched here, on
* open, because the Orders list drains up to twelve pages of a hundred rows and
* would otherwise carry an array it never reads.
*
* Two things this deliberately does NOT do:
*
* - It never reads `booking.consignmentid` or `booking.consignmentstatus`.
* Pickup-complete sets those from the FIRST leg only, for the benefit of
* older single-drop screens, so on a three-drop pickup they are Chennai's
* status wearing the whole booking's name. Per-leg state comes from the
* destination's own `stage` and `consignmentid`, never from the booking's.
*
* - It never invents a tracking number, stage or ETA. Those are minted at
* pickup-complete (cxPickupFanout), so before the rider collects there is
* genuinely nothing to show, and a column of em dashes reads as missing
* data rather than as work that has not happened yet. The columns are
* dropped instead.
*/
/* The address the customer typed, in the order they typed it. Empty parts are
dropped rather than rendered as gaps: a destination may carry nothing but a
district until the customer completes it. */
const streetAddress = (destination) =>
[destination?.building, destination?.street, destination?.landmark]
.map((part) => String(part || '').trim())
.filter(Boolean)
.join(', ');
const DestinationRow = ({ destination, index, showShipmentColumns }) => {
const address = streetAddress(destination);
const expected = parseDoormileTimestamp(destination?.expecteddeliveryat);
const delivered = parseDoormileTimestamp(destination?.deliveredat);
const cod = Number(destination?.codamount) || 0;
const weight = Number(destination?.verifiedweightkg) || 0;
return (
<li className="flex gap-3 border-b border-border py-3 last:border-0">
{/* seq is the customer-facing position and the {index} in
PATCH /customer/bookings/{ref}/destinations/{index}. Shown one-based
as the customer counts them, and never re-sorted. */}
<span className="mt-0.5 shrink-0 text-caption font-semibold tabular-nums text-ink-3">
{String(index + 1).padStart(2, '0')}
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<p className="text-body-sm font-semibold text-ink-1">
{destination?.districtname || 'Destination'}
</p>
{destination?.statename && (
<span className="text-caption text-ink-3">{destination.statename}</span>
)}
<span className="text-caption text-ink-3">
{`· ${plural(Number(destination?.packagecount) || 0, 'package')}`}
</span>
{showShipmentColumns && destination?.stage && (
<StatusBadge status={destination.stage} dot size="sm" />
)}
</div>
{address && (
<p className="truncate text-caption text-ink-2" title={address}>
{address}
{destination?.pincode ? ` · ${destination.pincode}` : ''}
</p>
)}
{(destination?.recipientname || destination?.recipientphone) && (
<p className="text-caption text-ink-2">
{destination.recipientname || 'Recipient'}
{destination.recipientphone ? ` · ${destination.recipientphone}` : ''}
</p>
)}
{destination?.instructions && (
<p className="text-caption italic text-ink-3">{destination.instructions}</p>
)}
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-caption text-ink-3">
{showShipmentColumns && destination?.trackingno && (
<span className="font-medium tabular-nums text-ink-2">{destination.trackingno}</span>
)}
{cod > 0 && <span>{`COD ${currency(cod)}`}</span>}
{weight > 0 && <span>{`${weight} kg verified`}</span>}
{delivered.isValid() ? (
<span>{`Delivered ${delivered.format('DD MMM, hh:mm A')}`}</span>
) : (
expected.isValid() && <span>{`Expected ${expected.format('DD MMM, hh:mm A')}`}</span>
)}
</div>
</div>
</li>
);
};
export function BookingDestinationsDrawer({ bookingId, bookingNo, open, onOpenChange }) {
/* Fetched on open, never on list render. `enabled` is what keeps this from
firing for every row the operator scrolls past. */
const { data: booking, isLoading, isError } = useQuery({
queryKey: ['doormile', 'booking', bookingId],
queryFn: () => getBooking(bookingId),
enabled: Boolean(open && bookingId),
});
const destinations = Array.isArray(booking?.destinations) ? booking.destinations : [];
/* Tracking numbers and stages are minted together at pickup-complete, so
"any leg has one" is the same question as "has this pickup been collected".
Asked across all legs rather than per row, so the columns appear or vanish
as a set instead of ragged. */
const showShipmentColumns = destinations.some(
(destination) => destination?.trackingno || destination?.stage
);
const totalPackages = destinations.reduce(
(sum, destination) => sum + (Number(destination?.packagecount) || 0),
0
);
return (
<Drawer
open={open}
onOpenChange={onOpenChange}
icon={MapPin}
title={bookingNo ? `${bookingNo} · drops` : 'Drops'}
description={
destinations.length
? `${plural(destinations.length, 'destination')} · ${plural(totalPackages, 'package')}`
: undefined
}
size="lg"
>
<div className="px-5 py-4">
{isLoading && <LoadingState message="Loading drops" />}
{isError && !isLoading && (
<EmptyState
icon={Package}
title="Could not load the drops"
description="The booking detail request failed. Close this and try again."
/>
)}
{!isLoading && !isError && destinations.length === 0 && (
/* Two different situations reach here and the copy has to fit both: a
console-created booking genuinely has no destination rows, and a
console running ahead of the backend gets no `destinations` key at
all. Neither is an error worth alarming an operator about. */
<EmptyState
icon={Package}
title="No separate drops recorded"
description="This booking has a single delivery address, shown on the row itself."
/>
)}
{!isLoading && destinations.length > 0 && (
<>
{!showShipmentColumns && (
<p className="mb-3 text-caption text-ink-3">
Tracking numbers appear once the rider completes pickup. Each drop becomes its own
shipment then.
</p>
)}
<ol className="min-w-0">
{destinations.map((destination, index) => (
<DestinationRow
key={destination?.bookingdestinationid ?? index}
destination={destination}
index={index}
showShipmentColumns={showShipmentColumns}
/>
))}
</ol>
</>
)}
</div>
</Drawer>
);
}
export default BookingDestinationsDrawer;

View File

@@ -20,7 +20,8 @@ import {
import { useQuery, useQueryClient } from '@tanstack/react-query';
import dayjs from 'dayjs';
import AddressAutocomplete, { geocodeAddress } from '@/components/doormile/AddressAutocomplete';
import AddressAutocomplete from '@/components/doormile/AddressAutocomplete';
import MapPinPickerModal from '@/components/doormile/MapPinPickerModal';
import { HubLocationSelect } from '@/components/doormile/HubLocationSelect';
import {
createExpressBooking,
@@ -30,6 +31,14 @@ import {
getTenantLocations
} from '@/api/doormile/endpoints';
import { calculateDrivingDistance, calculateTotalCharge, getLastRouteDurationMin } from '@/lib/distance';
import { useHubs } from '@/lib/doormileHooks';
import {
PICKUP_SOURCE,
buildAnchors,
buildFlowFields,
flowForDraft,
flowLabel
} from '@/lib/orderFlow';
import { OpenToast } from '@/components/third-party/OpenToast';
const SERVICE_OPTIONS = ['Normal', 'Fast', 'Superfast'];
@@ -95,6 +104,13 @@ export default function CreateOrder() {
const [pickCust, setPickCust] = useState(EMPTY_PARTY);
const [dropCust, setDropCust] = useState(EMPTY_PARTY);
// Where the parcel is collected from. 'hub' copies the selected business
// location onto the pickup block, which is what this form has always done.
// 'customer' leaves the pickup fields to the operator, because the collection
// point is a doorstep that no saved location describes.
const [pickupMode, setPickupMode] = useState(PICKUP_SOURCE.HUB);
const isCustomerPickup = pickupMode === PICKUP_SOURCE.CUSTOMER;
const [pickupDate, setPickupDate] = useState(dayjs().format('YYYY-MM-DD'));
const [selectedSlotTime, setSelectedSlotTime] = useState('09:00');
const [serviceOption, setServiceOption] = useState('Normal');
@@ -109,6 +125,9 @@ export default function CreateOrder() {
const [isCustomerModalOpen, setIsCustomerModalOpen] = useState(false);
const [searchCustQuery, setSearchCustQuery] = useState('');
// Map pin picker modal target ('pickup' | 'drop' | null)
const [mapPickerTarget, setMapPickerTarget] = useState(null);
// Queries
const { data: staffTenants = [], isLoading: isTenantsLoading } = useQuery({
queryKey: ['adminTenants'],
@@ -132,6 +151,17 @@ export default function CreateOrder() {
queryFn: getAdminPricing
});
// Facilities, so a DROP that lands on one is recognised as a hub rather than
// treated as a customer's address. Without this, a rider bringing a parcel
// back to the hub would be booked as point-to-point and routed as if the hub
// were somebody's house.
const { data: hubList = [] } = useHubs();
const flowAnchors = useMemo(
() => buildAnchors({ hubs: hubList, tenantLocations: tenantLocations }),
[hubList, tenantLocations]
);
const { data: customerList = [], isLoading: isCustomersLoading } = useQuery({
queryKey: ['adminCustomers'],
queryFn: getAdminCustomers,
@@ -148,24 +178,30 @@ export default function CreateOrder() {
const sortedLocations = useMemo(() => sortLocations(tenantLocations), [tenantLocations]);
// Auto-select single location
// Auto-select single location.
//
// The location is still selected under a customer pickup — it is the client's
// servicing origin and the order belongs to it — but its address is NOT
// copied over the pickup block, which would silently overwrite the doorstep
// the operator just typed with the kitchen's address.
useEffect(() => {
if (sortedLocations.length === 1) {
const loc = sortedLocations[0];
setSelectedLocationId(String(loc.tenantlocationid || loc.locationid));
setSelectedLocation(loc);
applyLocationToPickup(loc);
if (!isCustomerPickup) applyLocationToPickup(loc);
} else if (sortedLocations.length === 0) {
setSelectedLocationId('');
setSelectedLocation(null);
}
}, [sortedLocations]);
}, [sortedLocations, isCustomerPickup]);
const applyLocationToPickup = (loc) => {
if (!loc) return;
const lat = loc.latitude ?? loc.lat ?? loc.pickuplatitude ?? loc.locationlat;
const lng = loc.longitude ?? loc.lng ?? loc.long ?? loc.pickuplongitude ?? loc.locationlong;
// @ts-ignore
setPickCust((prev) => ({
...prev,
firstname: loc.contactname || loc.contactperson || prev.firstname || loc.locationname || 'Pickup Point',
@@ -190,9 +226,42 @@ export default function CreateOrder() {
}
const loc = sortedLocations.find((l) => String(l.tenantlocationid || l.locationid) === locId);
setSelectedLocation(loc || null);
if (loc) applyLocationToPickup(loc);
if (loc && !isCustomerPickup) applyLocationToPickup(loc);
};
// Switching collection mode.
//
// Going to 'customer' CLEARS the pickup block rather than leaving the hub's
// address sitting in it. A half-cleared form is the dangerous state here: the
// operator retypes the address, misses the coordinates, and books a doorstep
// collection that is still pinned on the kitchen — which routes a rider to
// the wrong end of the city and prices the job off the wrong leg.
const handleChangePickupMode = (mode) => {
if (mode === pickupMode) return;
setPickupMode(mode);
if (mode === PICKUP_SOURCE.CUSTOMER) {
setPickCust(EMPTY_PARTY);
} else if (selectedLocation) {
applyLocationToPickup(selectedLocation);
}
};
// What this order will be booked as. The pickup end is whatever the operator
// chose; the drop end is measured, because a free-typed delivery address that
// lands on a facility is a return to the hub, not a delivery to a customer.
const draftFlow = useMemo(
() =>
flowForDraft({
pickupSource: pickupMode,
pickupLat: pickCust.latitude,
pickupLng: pickCust.longitude,
dropLat: dropCust.latitude,
dropLng: dropCust.longitude,
anchors: flowAnchors
}),
[pickupMode, pickCust.latitude, pickCust.longitude, dropCust.latitude, dropCust.longitude, flowAnchors]
);
// Tenant Pricing
const clientRate = useMemo(() => {
const list = Array.isArray(pricingList) ? pricingList : [];
@@ -232,6 +301,45 @@ export default function CreateOrder() {
};
}, [pickCust.latitude, pickCust.longitude, dropCust.latitude, dropCust.longitude]);
// First-mile ("deadhead") leg.
//
// On a hub run the rider starts where the parcel is, so the only distance
// that exists is pickup → drop. A customer pickup adds a leg BEFORE that:
// the rider travels from the servicing location out to the customer's door,
// carrying nothing. It is real distance, real time and real fuel, and until
// now nothing in this console measured it.
//
// Measured and shown, NOT billed. What to charge for an empty leg is a
// tariff decision, and quietly folding it into every customer-pickup order's
// price would change what clients are invoiced without anyone choosing that.
// `totalCharge` below is deliberately left on the loaded leg alone.
const [deadhead, setDeadhead] = useState({ distance: 0, resolved: false });
useEffect(() => {
const oLat = Number(selectedLocation?.latitude);
const oLng = Number(selectedLocation?.longitude);
const pLat = Number(pickCust.latitude);
const pLng = Number(pickCust.longitude);
if (!isCustomerPickup || !oLat || !oLng || !pLat || !pLng) {
setDeadhead({ distance: 0, resolved: false });
return;
}
let active = true;
calculateDrivingDistance({ latitude: oLat, longitude: oLng }, { latitude: pLat, longitude: pLng })
.then((distance) => {
if (active) setDeadhead({ distance: Math.max(0, distance || 0), resolved: true });
})
.catch(() => {
if (active) setDeadhead({ distance: 0, resolved: false });
});
return () => {
active = false;
};
}, [isCustomerPickup, selectedLocation?.latitude, selectedLocation?.longitude, pickCust.latitude, pickCust.longitude]);
// Total Charge calculation
const totalCharge = useMemo(() => {
const base = Number(clientRate?.baseprice) || 40;
@@ -248,6 +356,7 @@ export default function CreateOrder() {
// Place selection helpers
const handlePlaceSelect = (partyType, place) => {
if (!place) return;
const parsed = { suburb: '', city: '', postcode: '' };
(place.address_components || []).forEach((c) => {
(c.types || []).forEach((type) => {
@@ -257,19 +366,22 @@ export default function CreateOrder() {
});
});
const lat = place.geometry?.location?.lat ? place.geometry.location.lat() : '';
const lng = place.geometry?.location?.lng ? place.geometry.location.lng() : '';
const address = place.formatted_address || '';
const lat = place.latitude != null ? place.latitude : (place.geometry?.location?.lat ? place.geometry.location.lat() : '');
const lng = place.longitude != null ? place.longitude : (place.geometry?.location?.lng ? place.geometry.location.lng() : '');
const address = place.formatted_address || place.name || '';
const suburb = place.suburb || parsed.suburb;
const city = place.city || parsed.city;
const postcode = place.postcode || parsed.postcode;
const setter = partyType === 'pickup' ? setPickCust : setDropCust;
setter((prev) => ({
...prev,
address,
suburb: parsed.suburb || prev.suburb,
city: parsed.city || prev.city,
postcode: parsed.postcode || prev.postcode,
latitude: lat,
longitude: lng
address: address || prev.address,
suburb: suburb || prev.suburb,
city: city || prev.city,
postcode: postcode || prev.postcode,
latitude: lat !== '' ? lat : prev.latitude,
longitude: lng !== '' ? lng : prev.longitude
}));
};
@@ -320,6 +432,21 @@ export default function CreateOrder() {
OpenToast('Enter pickup address', 'warning', 2500);
return;
}
// Pickup coordinates were never checked here. That was survivable only
// while the pickup was always a saved location, which supplies its own
// coordinates — a hand-typed collection address that fails to geocode
// submits 0,0, and an order pinned at 0,0 is unroutable, unpriceable and
// invisible to every zone filter.
if (!Number(pickCust.latitude) || !Number(pickCust.longitude)) {
OpenToast(
isCustomerPickup
? 'Pick the collection address from the suggestions, or drop a map pin, so it has coordinates'
: 'The selected hub has no coordinates — set them on the Hubs page before booking from it',
'warning',
4000
);
return;
}
if (!dropCust.firstname.trim()) {
OpenToast('Enter delivery contact name', 'warning', 2500);
return;
@@ -369,7 +496,23 @@ export default function CreateOrder() {
service_option: SERVICE_OPTIONS.includes(serviceOption) ? serviceOption : 'Normal',
finalprice,
notes: notes || '',
parcels
parcels,
// Which flow this is, and which hub owns it. Sent even where the backend
// may not store them yet: an unknown key is ignored server-side, and the
// console classifies from coordinates in the meantime, so nothing depends
// on these landing. The day they are persisted, every order created from
// here carries its own type instead of being re-derived on each load.
...buildFlowFields({
flow: draftFlow.flow,
servicingHubId:
draftFlow.originAnchor?.hubid ?? draftFlow.destAnchor?.hubid ?? selectedLocation?.hubid ?? null,
tenantLocationId: selectedLocationId,
// The collection point itself, so the order records what KIND of place
// it leaves from — a base, a client's site, or a customer's door. The
// backend stores it and the rider app titles the stop with it; without
// it every logistics pickup reads as the rider's own base name.
pickupAnchor: draftFlow.originAnchor
})
};
setIsSubmitting(true);
@@ -523,10 +666,77 @@ export default function CreateOrder() {
</div>
</div>
{selectedLocation && (
<span className="text-[11px] font-semibold px-2.5 py-0.5 rounded-full bg-emerald-50 text-emerald-700 border border-emerald-200">
{selectedLocation.locationname || 'Hub Selected'}
{/* The badge names the COLLECTION POINT, so it has to stop
naming the hub the moment the hub is not where we collect. */}
{isCustomerPickup ? (
<span className="text-[11px] font-semibold px-2.5 py-0.5 rounded-full bg-violet-50 text-violet-700 border border-violet-200">
{flowLabel(draftFlow.flow)}
</span>
) : (
selectedLocation && (
<span className="text-[11px] font-semibold px-2.5 py-0.5 rounded-full bg-emerald-50 text-emerald-700 border border-emerald-200">
{selectedLocation.locationname || 'Hub Selected'}
</span>
)
)}
</div>
{/* Collect from the client's own location, or from a customer's
door. This is the switch the whole order flow turns on: it
decides whether the pickup block is filled from the saved
location or typed, and it is what the board later reads to
draw a doorstep collection as its own stop on the route. */}
<div className="rounded-xl border border-slate-200 bg-slate-50/70 p-3">
<p className="text-[11px] font-semibold text-slate-700 mb-2">Collect from</p>
<div className="grid grid-cols-2 gap-2">
{[
{
mode: PICKUP_SOURCE.HUB,
icon: Building2,
title: 'Business Hub',
hint: 'Client kitchen, depot or branch'
},
{
mode: PICKUP_SOURCE.CUSTOMER,
icon: Users,
title: 'Customer Pickup',
hint: "Collect at a customer's own address"
}
].map(({ mode, icon: Icon, title, hint }) => {
const active = pickupMode === mode;
return (
<button
key={mode}
type="button"
onClick={() => handleChangePickupMode(mode)}
aria-pressed={active}
className={`flex items-start gap-2.5 rounded-lg border p-2.5 text-left transition-colors cursor-pointer ${
active
? 'border-black bg-white shadow-2xs ring-2 ring-black/10'
: 'border-slate-200 bg-white/60 hover:border-slate-300 hover:bg-white'
}`}
>
<Icon
className={`w-4 h-4 mt-0.5 shrink-0 ${active ? 'text-slate-900' : 'text-slate-400'}`}
/>
<span className="min-w-0">
<span
className={`block text-xs font-bold ${active ? 'text-slate-900' : 'text-slate-600'}`}
>
{title}
</span>
<span className="block text-[10px] text-slate-500 leading-tight mt-0.5">{hint}</span>
</span>
</button>
);
})}
</div>
{isCustomerPickup && (
<p className="mt-2 text-[10px] text-violet-700 bg-violet-50 border border-violet-200 rounded-lg px-2.5 py-1.5 leading-relaxed">
The rider travels out to this address to collect. Pick it from the suggestions or drop a
map pin so it carries coordinates — the first-mile leg is priced and routed from them.
</p>
)}
</div>
@@ -566,10 +776,11 @@ export default function CreateOrder() {
id="pickup-address"
label="Pickup Address"
required
placeholder="Search address or type location"
placeholder="Search address, building or locality"
value={pickCust.address}
onChange={(text) => setPickCust((prev) => ({ ...prev, address: text }))}
onPlaceSelected={(place) => handlePlaceSelect('pickup', place)}
onOpenMapPin={() => setMapPickerTarget('pickup')}
/>
</div>
@@ -676,11 +887,12 @@ export default function CreateOrder() {
id="drop-address"
label="Delivery Address"
required
placeholder="Search delivery address or locality"
placeholder="Search delivery address, society or landmark"
value={dropCust.address}
bias={pickCust.latitude ? { lat: Number(pickCust.latitude), lng: Number(pickCust.longitude) } : undefined}
onChange={(text) => setDropCust((prev) => ({ ...prev, address: text }))}
onPlaceSelected={(place) => handlePlaceSelect('drop', place)}
onOpenMapPin={() => setMapPickerTarget('drop')}
/>
</div>
@@ -970,11 +1182,35 @@ export default function CreateOrder() {
{/* Journey Legs */}
<div className="space-y-3 py-1 text-xs">
{/* The empty run out to the customer's door. Drawn as its own
leg because that is what it is — the rider covers it before
the parcel exists on the trip. */}
{isCustomerPickup && selectedLocation && (
<>
<div className="flex items-start gap-2.5">
<div className="w-2.5 h-2.5 rounded-full bg-slate-300 ring-4 ring-slate-100 mt-1 shrink-0" />
<div className="min-w-0 flex-1">
<div className="font-semibold text-slate-500 truncate">
{selectedLocation.locationname || 'Servicing Hub'}
</div>
<div className="text-[11px] text-slate-400 line-clamp-1">
Rider starts here · travels empty
</div>
</div>
</div>
<div className="ml-1 pl-2.5 border-l border-dashed border-slate-200 h-2" />
</>
)}
<div className="flex items-start gap-2.5">
<div className="w-2.5 h-2.5 rounded-full bg-emerald-500 ring-4 ring-emerald-100 mt-1 shrink-0" />
<div
className={`w-2.5 h-2.5 rounded-full mt-1 shrink-0 ${
isCustomerPickup ? 'bg-violet-500 ring-4 ring-violet-100' : 'bg-emerald-500 ring-4 ring-emerald-100'
}`}
/>
<div className="min-w-0 flex-1">
<div className="font-semibold text-slate-900 truncate">
{pickCust.firstname || selectedLocation?.locationname || 'Pickup Point'}
{pickCust.firstname || (isCustomerPickup ? 'Collection Point' : selectedLocation?.locationname) || 'Pickup Point'}
</div>
<div className="text-[11px] text-slate-500 line-clamp-1">{pickCust.address || 'Address not selected'}</div>
</div>
@@ -1010,6 +1246,22 @@ export default function CreateOrder() {
</div>
</div>
{/* Shown only when it exists, and labelled as unbilled so nobody
reconciles the invoice against the wrong number. */}
{isCustomerPickup && deadhead.resolved && (
<div className="flex items-center justify-between rounded-lg border border-violet-200 bg-violet-50 px-3 py-2">
<div className="min-w-0">
<div className="text-[10px] font-bold text-violet-700 uppercase tracking-wider">
First-mile leg
</div>
<div className="text-[10px] text-violet-600 leading-tight">
Hub → collection point · not billed
</div>
</div>
<div className="text-sm font-black text-violet-900 shrink-0 ml-2">{deadhead.distance} km</div>
</div>
)}
{/* Rate Card Info */}
<div className="text-xs space-y-2 py-2 border-y border-slate-100">
<div className="flex justify-between text-slate-600">
@@ -1155,6 +1407,22 @@ export default function CreateOrder() {
</div>
</div>
)}
{/* Map Pin Picker Modal for Pinpoint Accuracy */}
<MapPinPickerModal
isOpen={Boolean(mapPickerTarget)}
onClose={() => setMapPickerTarget(null)}
title={mapPickerTarget === 'pickup' ? 'Pin Exact Pickup Location' : 'Pin Exact Delivery Location'}
initialAddress={mapPickerTarget === 'pickup' ? pickCust.address : dropCust.address}
initialLat={mapPickerTarget === 'pickup' ? pickCust.latitude : dropCust.latitude}
initialLng={mapPickerTarget === 'pickup' ? pickCust.longitude : dropCust.longitude}
onConfirm={(place) => {
if (mapPickerTarget) {
handlePlaceSelect(mapPickerTarget, place);
}
setMapPickerTarget(null);
}}
/>
</div>
);
}

View File

@@ -38,11 +38,13 @@ import {
requiredSheetColumns
} from '@/lib/bulkOrderColumns';
import { calculateDrivingDistance, calculateTotalCharge } from '@/lib/distance';
import { useHubs } from '@/lib/doormileHooks';
import { PICKUP_SOURCE, buildAnchors } from '@/lib/orderFlow';
import { buildBulkBookingPayloads, rowPickupOf } from '@/lib/bulkOrderPayload';
import { OpenToast } from '@/components/third-party/OpenToast';
// Nominatim serves this console's geocoding for free and asks for no more than
// one request a second in return. See handleProcessUsersToDrops.
const GEOCODE_INTERVAL_MS = 1100;
// Multi-provider geocoding service allows sub-300ms lookups
const GEOCODE_INTERVAL_MS = 250;
const buildDefaultPickupSlots = () => {
const arr = [];
@@ -85,6 +87,21 @@ export default function MultipleOrders() {
const [pricePerKm, setPricePerKm] = useState(0);
const [minKm, setMinKm] = useState(0);
// Where every row in this run is collected from.
//
// 'hub' — one shared collection point, the selected business location.
// This is what the page has always done, and stays the default.
// 'customer' — each ROW carries its own collection point, read from the
// sheet's Sender Name / Sender Phone / Sender Address columns.
//
// Those sender columns are not new. SHEET_HEADER_MAP has always mapped them
// (to `locationname` / `locationcontact` / `locationaddress`) and the page has
// always parsed them and then thrown them away, overwriting every row's
// origin with the hub. A tenant sending a file of doorstep collections was
// therefore booking every one of them against the kitchen.
const [pickupMode, setPickupMode] = useState(PICKUP_SOURCE.HUB);
const isCustomerPickup = pickupMode === PICKUP_SOURCE.CUSTOMER;
// Upload & selection state
const [uploadType, setUploadType] = useState(null); // 0: file, 1: selection
const [fileName, setFileName] = useState('');
@@ -120,6 +137,13 @@ export default function MultipleOrders() {
queryFn: getAdminPricing
});
const { data: hubList = [] } = useHubs();
const flowAnchors = useMemo(
() => buildAnchors({ hubs: hubList, tenantLocations }),
[hubList, tenantLocations]
);
const { data: customerList = [], isLoading: isCustomersLoading } = useQuery({
queryKey: ['adminCustomers'],
queryFn: getAdminCustomers,
@@ -169,11 +193,24 @@ export default function MultipleOrders() {
};
// Distance calculation helper
// The collection point for ONE row.
//
// `__pickup` is attached per row during processing when the run collects from
// customers; it travels on the row rather than on the page so a file can mix
// origins without one row silently re-addressing another. Falls back to the
// shared business location, which is every row's answer on a hub run.
const rowPickup = (customer) => rowPickupOf(customer, pickCust);
const calculateDistance = async (customer) => {
if (!customer || typeof customer !== 'object') throw new Error('Invalid customer data.');
if (!pickCust || typeof pickCust !== 'object') throw new Error('Pickup location missing.');
const origin = rowPickup(customer);
if (!origin || typeof origin !== 'object') throw new Error('Pickup location missing.');
const roundedDistance = await calculateDrivingDistance(pickCust, customer);
// Measured from THIS row's collection point. Measuring every row from the
// hub while collecting at a doorstep prices the wrong leg entirely — for a
// collection near the recipient it bills a cross-town run that no rider
// makes, and for one far from both it under-bills.
const roundedDistance = await calculateDrivingDistance(origin, customer);
const totalcharge = calculateTotalCharge(roundedDistance, basePrice, pricePerKm, minKm);
return { roundedDistance, totalcharge };
};
@@ -275,6 +312,12 @@ export default function MultipleOrders() {
if (!users.length) return;
setIsProcessingRows(true);
const newDrops = [];
// One lookup per DISTINCT sender address, not per row. A real tenant file
// is three home kitchens across thirteen drops — geocoding the same kitchen
// six times costs six rate-limited round trips for one answer, and risks
// the six rows landing on marginally different coordinates, which would
// then draw six collection pins where the operator expects one.
const senderPlaceCache = new Map();
// Set once the first address lookup has gone out, so the pause below is
// paid between lookups rather than before the first one.
let geocoded = false;
@@ -296,14 +339,56 @@ export default function MultipleOrders() {
[customer.address, customer.city, customer.postcode].filter(Boolean).join(', '),
{ bias: pickCust?.latitude ? { lat: Number(pickCust.latitude), lng: Number(pickCust.longitude) } : undefined }
);
if (place?.geometry?.location) {
lat = place.geometry.location.lat();
lng = place.geometry.location.lng();
if (place) {
lat = place.latitude != null ? place.latitude : (place.geometry?.location?.lat ? place.geometry.location.lat() : null);
lng = place.longitude != null ? place.longitude : (place.geometry?.location?.lng ? place.geometry.location.lng() : null);
}
}
// The row's own collection point, from the sheet's sender columns.
// Geocoded the same way the delivery address is — a pickup without
// coordinates is unroutable, and a row that cannot produce them keeps
// the shared hub rather than being booked at 0,0.
let rowOwnPickup = null;
if (isCustomerPickup) {
const senderAddress = String(customer.locationaddress || customer.senderaddress || '').trim();
if (senderAddress) {
if (!senderPlaceCache.has(senderAddress)) {
if (geocoded) await new Promise((resolve) => setTimeout(resolve, GEOCODE_INTERVAL_MS));
geocoded = true;
senderPlaceCache.set(
senderAddress,
await geocodeAddress(senderAddress, {
bias: pickCust?.latitude
? { lat: Number(pickCust.latitude), lng: Number(pickCust.longitude) }
: undefined
})
);
}
const senderPlace = senderPlaceCache.get(senderAddress);
const sLat = senderPlace?.latitude ?? senderPlace?.geometry?.location?.lat?.();
const sLng = senderPlace?.longitude ?? senderPlace?.geometry?.location?.lng?.();
if (sLat && sLng) {
rowOwnPickup = {
locationname: customer.locationname || 'Customer Pickup',
contactno: String(customer.locationcontact || '').replace(/\D/g, '').slice(0, 10),
address: senderPlace.formatted_address || senderAddress,
city: senderPlace.city || '',
pincode: senderPlace.postcode || '',
latitude: sLat,
longitude: sLng
};
}
}
}
const enrichedCustomer = {
...customer,
__pickup: rowOwnPickup,
// Surfaced on the row so an operator reviewing the preview can see
// which rows fell back to the hub because their sender address would
// not geocode, instead of discovering it after 200 orders are live.
__pickupFellBack: isCustomerPickup && !rowOwnPickup,
latitude: lat || null,
longitude: lng || null,
quantity: Number(customer.quantity) || 1,
@@ -323,6 +408,19 @@ export default function MultipleOrders() {
setDropCust(newDrops);
setIsProcessingRows(false);
const fellBack = newDrops.filter((d) => d.__pickupFellBack).length;
if (fellBack > 0) {
// Named, not silent. These rows will be collected from the hub, which is
// a different journey from the one the sheet asked for.
OpenToast(
`${fellBack} of ${newDrops.length} rows had no usable Sender Address — those will be collected from ${
pickCust?.locationname || 'the selected hub'
}`,
'warning',
6000
);
}
OpenToast(`Processed ${newDrops.length} delivery drops`, 'success', 2500);
};
@@ -448,32 +546,20 @@ export default function MultipleOrders() {
'YYYY-MM-DD HH:mm:ss'
);
const bookings = dropCust.map((customer) => ({
tenantid: Number(selectedTenantId),
pickupaddress: pickCust.address || '',
pickuppincode: pickCust.pincode || pickCust.postcode || '',
pickupcity: pickCust.city || '',
pickuplatitude: Number(pickCust.latitude) || 0,
pickuplongitude: Number(pickCust.longitude) || 0,
customer_phone: customer.contactno?.toString() || '',
customer_name: customer.firstname || '',
deliveryaddress: customer.address || '',
deliverypincode: customer.postcode?.toString() || '',
deliverycity: customer.city || '',
deliverylatitude: Number(customer.latitude) || 0,
deliverylongitude: Number(customer.longitude) || 0,
// The transform lives in lib/bulkOrderPayload so a test can drive the real
// thing against a real tenant file, rather than a copy of it that agrees
// today. Per row, never collapsed onto one shared origin: a run can mix a
// doorstep collection with a hub pickup that fell back, and writing the hub
// across all of them would silently re-address half the file.
const bookings = buildBulkBookingPayloads({
rows: dropCust,
sharedPickup: pickCust,
tenantId: selectedTenantId,
tenantLocationId: selectedLocationId,
deliverytime,
service_option: 'Normal',
finalprice: Number(customer.totalcharge) || 0,
notes: otherInstructions || '',
parcels: [
{
itemcategory: 'General',
itemdescription: 'Order',
declaredvalue: Number(customer.totalcharge) || 0
}
]
}));
notes: otherInstructions,
anchors: flowAnchors
});
setIsSubmitting(true);
try {
@@ -625,6 +711,56 @@ export default function MultipleOrders() {
/>
</div>
{/* Where this run collects from. The hub stays selected either
way — it is the servicing location the orders belong to, and
the fallback for any row whose sender address will not
geocode — but under 'customer' it stops being the address
written onto every booking. */}
<div className="rounded-lg border border-slate-200 bg-slate-50/70 p-2.5">
<p className="text-[11px] font-semibold text-slate-700 mb-2">Collect from</p>
<div className="grid grid-cols-2 gap-2">
{[
{ mode: PICKUP_SOURCE.HUB, title: 'Business Hub', hint: 'One shared origin' },
{ mode: PICKUP_SOURCE.CUSTOMER, title: 'Customer Pickup', hint: "Each row's sender address" }
].map(({ mode, title, hint }) => {
const active = pickupMode === mode;
return (
<button
key={mode}
type="button"
aria-pressed={active}
onClick={() => {
if (mode === pickupMode) return;
setPickupMode(mode);
// The drops already carry resolved origins, distances
// and prices computed against the OLD mode. Keeping
// them would show a priced, ready-to-dispatch list
// that silently disagrees with the switch just made.
setDropCust([]);
}}
className={`rounded-lg border px-2.5 py-2 text-left transition-colors cursor-pointer ${
active
? 'border-black bg-white shadow-2xs ring-2 ring-black/10'
: 'border-slate-200 bg-white/60 hover:border-slate-300 hover:bg-white'
}`}
>
<span className={`block text-xs font-bold ${active ? 'text-slate-900' : 'text-slate-600'}`}>
{title}
</span>
<span className="block text-[10px] text-slate-500 leading-tight mt-0.5">{hint}</span>
</button>
);
})}
</div>
{isCustomerPickup && (
<p className="mt-2 text-[10px] text-violet-700 bg-violet-50 border border-violet-200 rounded-lg px-2.5 py-1.5 leading-relaxed">
Each row is collected from its <strong>Sender Address</strong> column. Rows without a usable
one fall back to the hub above, and you will be told how many.
</p>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-800 mb-1.5">Pickup Date</label>
@@ -661,6 +797,13 @@ export default function MultipleOrders() {
<div className="min-w-0 flex-1">
<div className="font-bold truncate">{pickCust.locationname || 'Pickup Hub'}</div>
<div className="text-[11px] text-sky-800 line-clamp-1 mt-0.5">{pickCust.address || '—'}</div>
{/* This tile reads as "everything is collected here", so it
has to say when that is no longer true. */}
{isCustomerPickup && (
<div className="text-[10px] font-semibold text-sky-700 mt-1">
Servicing hub only — rows are collected from their own sender addresses
</div>
)}
</div>
</div>
) : (
@@ -887,6 +1030,14 @@ export default function MultipleOrders() {
<tr>
<th className="py-3 pl-4 pr-2 w-8">#</th>
<th className="py-3 px-3 w-40">Customer</th>
{/* Only in customer-pickup mode, and deliberately BEFORE the
delivery address: a geocoded sender line is the one thing
on this screen the operator has not seen with their own
eyes. A coarse "Edayarpalayam, Coimbatore" resolves to a
landmark in that locality, not the kitchen, and a rider
sent there arrives at the wrong door. Better caught here
than after thirteen orders are live. */}
{isCustomerPickup && <th className="py-3 px-3">Collect from</th>}
<th className="py-3 px-3">Address</th>
<th className="py-3 px-2 w-16 text-center">Qty</th>
<th className="py-3 px-2 w-24 text-center">Cash (₹)</th>
@@ -905,6 +1056,30 @@ export default function MultipleOrders() {
<div className="font-bold text-slate-900 text-xs truncate max-w-[150px]">{row.firstname || '—'}</div>
<div className="text-[11px] text-slate-600 font-mono font-medium mt-0.5">{row.contactno || '—'}</div>
</td>
{isCustomerPickup && (
<td className="py-3 px-3 min-w-[180px]">
{row.__pickup ? (
<>
<div
className="truncate max-w-[180px] text-[11px] font-semibold text-violet-800"
title={row.__pickup.address}
>
{row.__pickup.locationname || 'Customer Pickup'}
</div>
<div
className="truncate max-w-[180px] text-[10px] text-slate-500 mt-0.5"
title={row.__pickup.address}
>
{row.__pickup.address}
</div>
</>
) : (
<div className="text-[10px] font-semibold text-amber-700" title="Sender address could not be resolved">
Fell back to hub
</div>
)}
</td>
)}
<td className="py-3 px-3 min-w-[220px]">
{needsAddress ? (
<AddressAutocomplete

View File

@@ -4,7 +4,7 @@ import { motion } from 'framer-motion';
import dayjs from 'dayjs';
import { Ban, Bike, FileSpreadsheet, MapPin, Package, Plus, Sparkles, Truck, Upload, UserCheck } from 'lucide-react';
import { Button, ConfirmModal, DataTable, DatePicker, EmptyState, Modal, PageHeader, SearchInput, Stack, StatusBadge, Tabs, ZoneSelector } from '@/components/ds';
import { useBulkCancelBookings, useCustomers, useRiderOptions } from '@/lib/doormileHooks';
import { useBulkCancelBookings, useCustomers, useFlowAnchors, useRiderOptions } from '@/lib/doormileHooks';
import { useZone } from '@/lib/ZoneContext';
import { createAutomationDeliveries } from '@/api/doormile';
import { assignMilerToBooking, notifyMiler } from '@/api/doormile/endpoints';
@@ -12,8 +12,11 @@ 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';
import { FLOW, classifyBooking } from '@/lib/orderFlow';
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
import { currency, exportRows, km as formatKm, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
import { dropSummary, isMultiDrop } from '@/lib/bookingDrops';
import { BookingDestinationsDrawer } from './BookingDestinationsDrawer';
/**
* Orders — every booking, grouped by where it sits in the operator's workflow.
@@ -82,6 +85,9 @@ export default function Orders() {
const debouncedSearch = useDebouncedValue(search);
const [selectedIds, setSelectedIds] = useState([]);
const [cancelOpen, setCancelOpen] = useState(false);
/* The row whose drops are open. Holds the row itself, not just the id, so the
drawer can title itself from `bookingno` without waiting for the fetch. */
const [dropsRow, setDropsRow] = useState(null);
const { selectedZone, selectedZoneId, isAllZones, matchesZone } = useZone();
@@ -165,6 +171,12 @@ export default function Orders() {
const tabStatuses = useMemo(() => statusesInGroup(tab), [tab]);
/* Which end of each booking is a facility. This page reads RAW bookings
rather than the deliveries feed, so it has to classify for itself — but
against the same shared anchor list, so a row cannot read "customer
pickup" here and "hub run" on Dispatch. */
const { data: flowAnchors = [] } = useFlowAnchors((bookings || []).map((b) => b?.tenantid));
const rows = useMemo(
() =>
(zoneBookings || [])
@@ -315,17 +327,37 @@ export default function Orders() {
customer?.firstname ||
customer?.name ||
(row.appcustomerid ? `Customer #${row.appcustomerid}` : 'Drop');
/* "Pickup" alone reads as "collected from our hub", which is what it
always meant. Once it can also mean a doorstep, the row has to say
which — otherwise two very different journeys look identical in the
list. Silent on an ordinary hub run, so the label only appears
where it carries information. */
const flow = classifyBooking(row, flowAnchors);
const isCustomerPickup = flow.flow !== FLOW.FORWARD && flow.pickupSource === 'customer';
return (
<div className="min-w-0">
<p className="truncate text-body-sm text-ink-2" title={row.pickupaddress || ''}>
<span className="font-medium text-ink-1">Pickup</span>
<span className={isCustomerPickup ? 'font-medium text-violet-700' : 'font-medium text-ink-1'}>
{isCustomerPickup ? 'Customer pickup' : 'Pickup'}
</span>
{row.pickupaddress ? ` · ${row.pickupaddress}` : ' · —'}
</p>
<p className="truncate text-caption text-ink-3" title={row.deliveryaddress || ''}>
<span className="font-medium text-ink-2">{dropName}</span>
{row.deliveryaddress ? ` · ${row.deliveryaddress}` : ''}
{row.parcels?.length > 1 ? ` · ${row.parcels.length} parcels` : ''}
</p>
{/* Collapsed, a multi-drop row reads as ONE pickup request that
created several shipments — never as one of them. The flat
`deliveryaddress` it would otherwise show is destination 0
mirrored by CreateCxBooking, so on a three-drop pickup it is
Chennai standing in for all three. Open the row for the legs. */}
{isMultiDrop(row) ? (
<p className="truncate text-caption text-ink-3">
<span className="font-medium text-violet-700">{dropSummary(row)}</span>
</p>
) : (
<p className="truncate text-caption text-ink-3" title={row.deliveryaddress || ''}>
<span className="font-medium text-ink-2">{dropName}</span>
{row.deliveryaddress ? ` · ${row.deliveryaddress}` : ''}
{row.parcels?.length > 1 ? ` · ${row.parcels.length} parcels` : ''}
</p>
)}
</div>
);
},
@@ -361,6 +393,21 @@ export default function Orders() {
return <StatusBadge status="arrived" dot size="sm" />;
}
if (rawStatus === 'converted_to_consignment') {
/* THE ONE THAT WOULD SILENTLY LIE.
`pickupbookings.consignmentid` names the FIRST leg only — set
that way at pickup-complete so single-drop screens keep working
— and `consignmentstatus` is joined from it. On a three-drop
pickup that is Chennai's status wearing the whole booking's
name, and rendering it here would confidently report "Delivered"
while two parcels are still in transit.
A multi-drop row therefore shows the PICKUP's own state, which
is genuinely finished once the rider has collected. Per-leg
status lives in the drawer, read from each destination's own
stage. */
if (isMultiDrop(row)) {
return <StatusBadge status="picked" dot size="sm" />;
}
const cStatus = row.consignmentstatus ?? row.consignment_status;
return <StatusBadge status={cStatus || 'picked'} dot size="sm" />;
}
@@ -476,6 +523,18 @@ export default function Orders() {
{!isAllZones && (
<span className="font-medium text-ink-2"> · Zone: {selectedZone.hubname}</span>
)}
{/* Search runs over the flat booking columns, and `deliveryaddress`
is destination 0 mirrored by CreateCxBooking. Searching
"Ernakulam" on a three-drop pickup whose first drop is Chennai
therefore finds nothing. The list has no destination array to
index — it carries only the counts, deliberately — so the honest
fix is to say which drop is searched rather than to fail
silently. */}
{debouncedSearch && (
<span className="text-ink-3">
{' '}· search covers the order number, pickup, notes and the first drop only
</span>
)}
{/* 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. */}
@@ -493,6 +552,12 @@ export default function Orders() {
columns={columns}
rows={rows}
getRowId={(row) => row.bookingid}
/* Opens the drops for ANY booking, not only multi-drop ones: the
per-destination recipient, instructions and COD exist nowhere else
in the console, and a row that is clickable only sometimes is worse
than one that always is. The checkbox cell stops propagation, so
this does not fight selection. */
onRowClick={(row) => setDropsRow(row)}
loading={isLoading}
refreshing={isFetching && !isLoading}
selectedIds={tab === 'pending' && !isAllZones ? selectedIds : undefined}
@@ -630,6 +695,15 @@ export default function Orders() {
})
}
/>
<BookingDestinationsDrawer
open={Boolean(dropsRow)}
onOpenChange={(next) => {
if (!next) setDropsRow(null);
}}
bookingId={dropsRow?.bookingid}
bookingNo={dropsRow?.bookingno || (dropsRow ? `#${dropsRow.bookingid}` : undefined)}
/>
</Stack>
);
}