updates on the autocomplete in the create order page
This commit is contained in:
@@ -23,6 +23,8 @@ import dayjs from 'dayjs';
|
||||
import AddressAutocomplete from '@/components/doormile/AddressAutocomplete';
|
||||
import MapPinPickerModal from '@/components/doormile/MapPinPickerModal';
|
||||
import { HubLocationSelect } from '@/components/doormile/HubLocationSelect';
|
||||
import { MapContainer, Marker, Polyline, TileLayer, useMap } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import {
|
||||
createExpressBooking,
|
||||
getAdminCustomers,
|
||||
@@ -30,7 +32,8 @@ import {
|
||||
getAdminTenants,
|
||||
getTenantLocations
|
||||
} from '@/api/doormile/endpoints';
|
||||
import { calculateDrivingDistance, calculateTotalCharge, getLastRouteDurationMin } from '@/lib/distance';
|
||||
import { calculateDrivingRoute, calculateTotalCharge, getLastRouteDurationMin } from '@/lib/distance';
|
||||
import { saveRecentAddress } from '@/lib/geocodingService';
|
||||
import { useHubs } from '@/lib/doormileHooks';
|
||||
import {
|
||||
PICKUP_SOURCE,
|
||||
@@ -41,6 +44,32 @@ import {
|
||||
} from '@/lib/orderFlow';
|
||||
import { OpenToast } from '@/components/third-party/OpenToast';
|
||||
|
||||
const miniPickupIcon = new L.DivIcon({
|
||||
className: 'mini-pickup-icon',
|
||||
html: `<div style="background-color: #10b981; color: white; width: 22px; height: 22px; border-radius: 9999px; display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 10px; border: 2px solid white; box-shadow: 0 2px 5px rgba(0,0,0,0.3);">P</div>`,
|
||||
iconSize: [22, 22],
|
||||
iconAnchor: [11, 11]
|
||||
});
|
||||
|
||||
const miniDropIcon = new L.DivIcon({
|
||||
className: 'mini-drop-icon',
|
||||
html: `<div style="background-color: #f97316; color: white; width: 22px; height: 22px; border-radius: 9999px; display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 10px; border: 2px solid white; box-shadow: 0 2px 5px rgba(0,0,0,0.3);">D</div>`,
|
||||
iconSize: [22, 22],
|
||||
iconAnchor: [11, 11]
|
||||
});
|
||||
|
||||
function MiniRouteBoundsAdjuster({ bounds }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (bounds && bounds.length >= 2) {
|
||||
try {
|
||||
map.fitBounds(bounds, { padding: [20, 20], maxZoom: 15 });
|
||||
} catch {}
|
||||
}
|
||||
}, [bounds, map]);
|
||||
return null;
|
||||
}
|
||||
|
||||
const SERVICE_OPTIONS = ['Normal', 'Fast', 'Superfast'];
|
||||
const ITEM_CATEGORIES = ['General', 'Food & Beverage', 'Grocery', 'Pharmacy', 'Documents', 'Electronics'];
|
||||
|
||||
@@ -164,10 +193,17 @@ export default function CreateOrder() {
|
||||
|
||||
const { data: customerList = [], isLoading: isCustomersLoading } = useQuery({
|
||||
queryKey: ['adminCustomers'],
|
||||
queryFn: getAdminCustomers,
|
||||
enabled: isCustomerModalOpen
|
||||
queryFn: getAdminCustomers
|
||||
});
|
||||
|
||||
const matchedCustomer = useMemo(() => {
|
||||
const cleanPhone = String(dropCust.contactno || '').replace(/\D/g, '');
|
||||
if (cleanPhone.length >= 8) {
|
||||
return (customerList || []).find((c) => String(c.phone || '').replace(/\D/g, '').endsWith(cleanPhone));
|
||||
}
|
||||
return null;
|
||||
}, [customerList, dropCust.contactno]);
|
||||
|
||||
const filteredCustomers = useMemo(() => {
|
||||
const q = searchCustQuery.trim().toLowerCase();
|
||||
if (!q) return customerList;
|
||||
@@ -274,7 +310,7 @@ export default function CreateOrder() {
|
||||
return defaultRule || list[0] || { baseprice: 40, priceperkm: 10, basedistance: 2 };
|
||||
}, [pricingList, selectedTenantId]);
|
||||
|
||||
// Route calculation
|
||||
// Route calculation with full polyline for mini-map preview
|
||||
useEffect(() => {
|
||||
const pLat = Number(pickCust.latitude);
|
||||
const pLng = Number(pickCust.longitude);
|
||||
@@ -282,18 +318,23 @@ export default function CreateOrder() {
|
||||
const dLng = Number(dropCust.longitude);
|
||||
|
||||
if (!pLat || !pLng || !dLat || !dLng) {
|
||||
setRoute({ distance: 0, minutes: null, resolved: false });
|
||||
setRoute({ distance: 0, minutes: null, polyline: [], resolved: false });
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
calculateDrivingDistance({ latitude: pLat, longitude: pLng }, { latitude: dLat, longitude: dLng })
|
||||
.then((distance) => {
|
||||
calculateDrivingRoute({ latitude: pLat, longitude: pLng }, { latitude: dLat, longitude: dLng })
|
||||
.then((res) => {
|
||||
if (!active) return;
|
||||
setRoute({ distance: Math.max(1, distance || 1), minutes: getLastRouteDurationMin() || 15, resolved: true });
|
||||
setRoute({
|
||||
distance: Math.max(1, res.distance || 1),
|
||||
minutes: res.minutes || 15,
|
||||
polyline: res.polyline || [[pLat, pLng], [dLat, dLng]],
|
||||
resolved: true
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setRoute({ distance: 0, minutes: null, resolved: false });
|
||||
if (active) setRoute({ distance: 0, minutes: null, polyline: [], resolved: false });
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -354,6 +395,25 @@ export default function CreateOrder() {
|
||||
return Math.max(base, calculated);
|
||||
}, [route, clientRate]);
|
||||
|
||||
// Active City & Hub Bias for smart address autocomplete
|
||||
const activeCityBias = useMemo(() => {
|
||||
if (selectedLocation?.latitude && selectedLocation?.longitude) {
|
||||
return {
|
||||
lat: Number(selectedLocation.latitude),
|
||||
lng: Number(selectedLocation.longitude),
|
||||
city: selectedLocation.city || selectedLocation.locationname || ''
|
||||
};
|
||||
}
|
||||
if (pickCust?.latitude && pickCust?.longitude) {
|
||||
return {
|
||||
lat: Number(pickCust.latitude),
|
||||
lng: Number(pickCust.longitude),
|
||||
city: pickCust.city || ''
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}, [selectedLocation, pickCust.latitude, pickCust.longitude, pickCust.city]);
|
||||
|
||||
// Place selection helpers
|
||||
const handlePlaceSelect = (partyType, place) => {
|
||||
if (!place) return;
|
||||
@@ -519,6 +579,15 @@ export default function CreateOrder() {
|
||||
try {
|
||||
const res = await createExpressBooking(payload);
|
||||
if (res?.success) {
|
||||
saveRecentAddress({
|
||||
formatted_address: dropCust.address,
|
||||
name: dropCust.firstname,
|
||||
suburb: dropCust.suburb,
|
||||
city: dropCust.city,
|
||||
postcode: dropCust.postcode,
|
||||
latitude: Number(dropCust.latitude),
|
||||
longitude: Number(dropCust.longitude)
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['doormile', 'bookings'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['ordersBulk'] });
|
||||
OpenToast('Order created successfully', 'success', 2500);
|
||||
@@ -776,11 +845,15 @@ export default function CreateOrder() {
|
||||
id="pickup-address"
|
||||
label="Pickup Address"
|
||||
required
|
||||
placeholder="Search address, building or locality"
|
||||
placeholder="Search area, street, society, pincode or paste link…"
|
||||
value={pickCust.address}
|
||||
bias={activeCityBias}
|
||||
onChange={(text) => setPickCust((prev) => ({ ...prev, address: text }))}
|
||||
onPlaceSelected={(place) => handlePlaceSelect('pickup', place)}
|
||||
onOpenMapPin={() => setMapPickerTarget('pickup')}
|
||||
onLandmarkExtracted={(lmk) => {
|
||||
setPickCust((prev) => (!prev.landmark ? { ...prev, landmark: lmk } : prev));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -851,6 +924,26 @@ export default function CreateOrder() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Smart Customer Auto-Fill Banner */}
|
||||
{matchedCustomer && (!dropCust.firstname || dropCust.firstname !== matchedCustomer.name) && (
|
||||
<div className="p-2.5 bg-blue-50/90 border border-blue-200 rounded-xl flex items-center justify-between gap-3 animate-in fade-in duration-200">
|
||||
<div className="flex items-center gap-2 text-xs text-blue-900">
|
||||
<span className="font-bold text-blue-700">✨ Existing Contact Found:</span>
|
||||
<span className="font-semibold">{matchedCustomer.name}</span>
|
||||
{matchedCustomer.phone && (
|
||||
<span className="font-mono text-blue-600 text-[11px]">({matchedCustomer.phone})</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePickCustomer(matchedCustomer)}
|
||||
className="h-7 px-3 bg-blue-600 hover:bg-blue-700 text-white text-xs font-semibold rounded-lg transition-colors cursor-pointer shrink-0 shadow-2xs"
|
||||
>
|
||||
Auto-Fill
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-800 mb-1">
|
||||
@@ -887,12 +980,15 @@ export default function CreateOrder() {
|
||||
id="drop-address"
|
||||
label="Delivery Address"
|
||||
required
|
||||
placeholder="Search delivery address, society or landmark"
|
||||
placeholder="Search area, street, society, pincode or paste link…"
|
||||
value={dropCust.address}
|
||||
bias={pickCust.latitude ? { lat: Number(pickCust.latitude), lng: Number(pickCust.longitude) } : undefined}
|
||||
bias={activeCityBias}
|
||||
onChange={(text) => setDropCust((prev) => ({ ...prev, address: text }))}
|
||||
onPlaceSelected={(place) => handlePlaceSelect('drop', place)}
|
||||
onOpenMapPin={() => setMapPickerTarget('drop')}
|
||||
onLandmarkExtracted={(lmk) => {
|
||||
setDropCust((prev) => (!prev.landmark ? { ...prev, landmark: lmk } : prev));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1246,6 +1342,52 @@ export default function CreateOrder() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mini Interactive Route Map Preview */}
|
||||
{route.resolved && Number(pickCust.latitude) && Number(dropCust.latitude) && (
|
||||
<div className="rounded-xl overflow-hidden border border-slate-200 shadow-2xs mt-2 relative">
|
||||
<div className="h-36 w-full relative z-0">
|
||||
<MapContainer
|
||||
center={[
|
||||
(Number(pickCust.latitude) + Number(dropCust.latitude)) / 2,
|
||||
(Number(pickCust.longitude) + Number(dropCust.longitude)) / 2
|
||||
]}
|
||||
zoom={12}
|
||||
scrollWheelZoom={false}
|
||||
dragging={false}
|
||||
zoomControl={false}
|
||||
attributionControl={false}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
||||
<Marker
|
||||
position={[Number(pickCust.latitude), Number(pickCust.longitude)]}
|
||||
icon={miniPickupIcon}
|
||||
/>
|
||||
<Marker
|
||||
position={[Number(dropCust.latitude), Number(dropCust.longitude)]}
|
||||
icon={miniDropIcon}
|
||||
/>
|
||||
{route.polyline && route.polyline.length > 0 && (
|
||||
<Polyline positions={route.polyline} color="#2563eb" weight={4} opacity={0.85} />
|
||||
)}
|
||||
<MiniRouteBoundsAdjuster
|
||||
bounds={[
|
||||
[Number(pickCust.latitude), Number(pickCust.longitude)],
|
||||
[Number(dropCust.latitude), Number(dropCust.longitude)]
|
||||
]}
|
||||
/>
|
||||
</MapContainer>
|
||||
</div>
|
||||
<div className="px-3 py-1.5 bg-slate-50 border-t border-slate-100 flex items-center justify-between text-[10px] text-slate-500 font-medium">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500" />
|
||||
Pickup ➔ <span className="w-2 h-2 rounded-full bg-orange-500" /> Delivery
|
||||
</span>
|
||||
<span className="font-semibold text-slate-700">{route.distance} km road path</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shown only when it exists, and labelled as unbilled so nobody
|
||||
reconciles the invoice against the wrong number. */}
|
||||
{isCustomerPickup && deadhead.resolved && (
|
||||
|
||||
Reference in New Issue
Block a user