updates on the bookings page and updated ai and more thngs
This commit is contained in:
592
src/pages/doormile/bookings/Bookings.jsx
Normal file
592
src/pages/doormile/bookings/Bookings.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}` : ''}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ? '↑' : (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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
194
src/pages/doormile/orders/BookingDestinationsDrawer.jsx
Normal file
194
src/pages/doormile/orders/BookingDestinationsDrawer.jsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user