updates on the create order design and select location updates

This commit is contained in:
2026-08-27 12:15:31 +05:30
parent 656e180675
commit c6607ae75d
5 changed files with 543 additions and 175 deletions

View File

@@ -353,15 +353,18 @@ export const fetchRidersList = async () => {
try {
const milers = await getMilers();
return (milers || []).map((val) => {
const name = val.displayname || val.authname || '';
const name =
val.displayname ||
[val.firstname, val.lastname].filter(Boolean).join(' ').trim() ||
val.authname ||
val.name ||
val.username ||
(val.userid ? `Rider #${val.userid}` : '');
return {
...val,
// Only append " | phone" when a phone actually exists — an
// unconditional template literal left a dangling " | " on every
// rider missing contactno, rendering literally in every dropdown
// that falls back to this default label (e.g. OrdersPreview.js,
// Preview.js's Change Rider dialog).
label: val.contactno ? `${name} | ${val.contactno}` : name
name,
displayname: name,
label: val.contactno || val.phone ? `${name} | ${val.contactno || val.phone}` : name
};
});
} catch (err) {
@@ -685,11 +688,55 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
const consignmentMap = new Map();
(consignments || []).forEach((c) => {
const id = c?.consignmentid ?? c?.id;
if (id != null) consignmentMap.set(String(id), c);
if (id != null) {
consignmentMap.set(String(id), c);
consignmentMap.set(Number(id), c);
}
});
const customerMap = new Map();
(customers || []).forEach((c) => {
const id = c.appcustomerid ?? c.customerid ?? c.id;
if (id != null) {
customerMap.set(String(id), c);
customerMap.set(Number(id), c);
}
});
const milerMap = new Map();
(milers || []).forEach((m) => {
const riderName =
m.displayname ||
[m.firstname, m.lastname].filter(Boolean).join(' ').trim() ||
m.authname ||
m.name ||
m.username ||
'';
const riderObj = { ...m, resolvedName: riderName };
if (m.userid != null) {
milerMap.set(String(m.userid), riderObj);
milerMap.set(Number(m.userid), riderObj);
}
if (m.milerid != null) {
milerMap.set(String(m.milerid), riderObj);
milerMap.set(Number(m.milerid), riderObj);
}
if (m.milerprofileid != null) {
milerMap.set(String(m.milerprofileid), riderObj);
milerMap.set(Number(m.milerprofileid), riderObj);
}
if (m.id != null) {
milerMap.set(String(m.id), riderObj);
milerMap.set(Number(m.id), riderObj);
}
});
const tenantMap = new Map();
(tenants || []).forEach((t) => {
if (t.tenantid != null) {
tenantMap.set(String(t.tenantid), t);
tenantMap.set(Number(t.tenantid), t);
}
});
const customerMap = new Map((customers || []).map((c) => [c.appcustomerid ?? c.customerid ?? c.id, c]));
const milerMap = new Map((milers || []).map((m) => [m.userid ?? m.milerid, m]));
const tenantMap = new Map((tenants || []).map((t) => [t.tenantid, t]));
const isDispatched = (b) => {
if (!b) return false;
@@ -706,17 +753,42 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
const dispatched = (bookings || []).filter(isDispatched);
const rows = dispatched.map((b) => {
const riderId = b.assignedmileruserid ?? b.assigned_miler_user_id ?? b.mileruserid ?? b.milerid ?? b.assignedmilerid;
const consignmentId = b.consignmentid ?? b.consignment_id;
const consignment = consignmentId != null ? consignmentMap.get(String(consignmentId)) : null;
const riderId =
b.assignedmileruserid ??
b.assigned_miler_user_id ??
b.mileruserid ??
b.milerid ??
b.assignedmilerid ??
consignment?.assignedmileruserid ??
consignment?.mileruserid ??
consignment?.milerid;
const customerId = b.appcustomerid ?? b.customerid ?? b.id;
const customer = customerMap.get(customerId);
const miler = milerMap.get(riderId) || (riderId != null ? milerMap.get(Number(riderId)) : null);
const tenant = tenantMap.get(b.tenantid);
const customer = customerId != null ? customerMap.get(String(customerId)) : null;
const miler = riderId != null ? (milerMap.get(String(riderId)) || milerMap.get(Number(riderId))) : null;
const tenant = b.tenantid != null ? (tenantMap.get(String(b.tenantid)) || tenantMap.get(Number(b.tenantid))) : null;
const charge = b.serviceoptions?.[0]?.estimatedprice;
const cStatus = consignmentStatusFor(b, consignmentMap);
const reached = b.reachedat ?? b.reached_at ?? b.reachedAt ?? b.reachedtime ?? b.reached_time;
const effectiveDeliveryStatus = deriveDeliveryStatus(b, consignmentMap);
const actualRiderName =
miler?.resolvedName ||
miler?.displayname ||
[miler?.firstname, miler?.lastname].filter(Boolean).join(' ').trim() ||
miler?.authname ||
miler?.name ||
b.milername ||
b.ridername ||
b.assignedmilername ||
consignment?.milername ||
consignment?.ridername ||
consignment?.assignedmilername ||
(riderId ? `Rider #${riderId}` : '');
return {
orderheaderid: b.bookingid ?? b.id,
deliveryid: b.bookingid ?? b.id,
@@ -731,13 +803,6 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
tenantadress: tenant?.primaryemail || '',
locationname: tenant?.tenantname || '',
locationsuburb: '',
// Was hardcoded '' — Dispatch.js's kitchen markers read this as the
// pickup business name (`o.pickupcustomer || o.kitchen_key || 'Unknown'`,
// Dispatch.js:1660), and no booking on this API carries a `kitchen_key`
// field at all, so every kitchen pin fell through to the literal string
// 'Unknown' — rendered as a "U" marker whose hover/popup then showed
// "Unknown". The tenant IS the kitchen for a B2B booking (same value
// already used for tenantname/locationname above), so reuse it here.
pickupcustomer: tenant?.tenantname || '',
pickupcontactno: '',
Pickupaddress: b.pickupaddress || '',
@@ -749,15 +814,10 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
deliveryaddress: b.deliveryaddress || '',
deliverylocation: b.deliveryaddress || '',
deliverysuburb: '',
ridername: miler?.displayname || miler?.name || (riderId ? `Rider #${riderId}` : ''),
ridername: actualRiderName,
userid: riderId,
// GET /admin/milers/:id/notify (and block/assign-vehicle) key off
// milerprofileid, not the userid stored on the booking — confirmed
// live (a booking's assignedmileruserid matches a miler's `userid`
// field, which 404s against /admin/milers/:id; milerprofileid is the
// real primary key of that resource).
milerprofileid: miler?.milerprofileid ?? miler?.id,
ridercontact: miler?.phone || miler?.contactno || '',
ridercontact: miler?.phone || miler?.contactno || b.riderphone || consignment?.riderphone || '',
expecteddeliverytime: b.serviceoptions?.[0]?.estimateddeliveryat,
// No route-plan data source (step order/transit time/cumulative km were
// computed by the old jupiter backend from the dispatch optimiser's

View File

@@ -0,0 +1,234 @@
import React, { useMemo, useState } from 'react';
import { Building2, Check, ChevronDown, MapPin, Search, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
const locationDisplayName = (loc) =>
(loc?.locationname && loc.locationname.trim()) || loc?.address || 'Unnamed location';
const isValidLocation = (loc) => {
const name = String(loc?.locationname || '').toLowerCase();
return !name.includes('retired') && !name.includes('do not use') && !name.includes('test row');
};
export function HubLocationSelect({
locations = [],
selectedLocationId = '',
onSelectLocation,
disabled = false,
placeholder = 'Select Hub Location',
variant = 'header', // 'header' | 'field'
className,
}) {
const [search, setSearch] = useState('');
const validLocations = useMemo(() => {
return (locations || [])
.filter(isValidLocation)
.sort((a, b) => {
if (Boolean(a.isprimary) !== Boolean(b.isprimary)) return a.isprimary ? -1 : 1;
return locationDisplayName(a).localeCompare(locationDisplayName(b));
});
}, [locations]);
const filtered = useMemo(() => {
if (!search.trim()) return validLocations;
const q = search.toLowerCase();
return validLocations.filter(
(loc) =>
locationDisplayName(loc).toLowerCase().includes(q) ||
loc.address?.toLowerCase().includes(q) ||
loc.city?.toLowerCase().includes(q) ||
loc.pincode?.includes(q)
);
}, [validLocations, search]);
const selectedLoc = useMemo(() => {
return validLocations.find(
(l) => String(l.tenantlocationid || l.locationid) === String(selectedLocationId)
);
}, [validLocations, selectedLocationId]);
const displayLabel = selectedLoc ? locationDisplayName(selectedLoc) : placeholder;
if (variant === 'field') {
return (
<DropdownMenu onOpenChange={(open) => !open && setSearch('')}>
<DropdownMenuTrigger asChild disabled={disabled}>
<button
type="button"
className={cn(
'flex h-10 w-full items-center justify-between gap-2 rounded-xl border border-slate-200 bg-white px-3 text-xs font-medium text-slate-900 transition-colors',
'hover:border-slate-300 focus:outline-none focus:ring-2 focus:ring-black/10 focus:border-black cursor-pointer shadow-2xs',
disabled && 'bg-slate-50 opacity-60 cursor-not-allowed hover:border-slate-200',
className
)}
>
<div className="flex items-center gap-2 min-w-0">
<Building2 className="h-4 w-4 shrink-0 text-slate-400" />
<span className={cn('truncate', !selectedLoc && 'text-slate-400 font-normal')}>
{displayLabel}
</span>
{selectedLoc?.isprimary && (
<span className="shrink-0 rounded-full bg-blue-50 px-2 py-0.5 text-[10px] font-bold text-blue-700 border border-blue-200">
PRIMARY
</span>
)}
</div>
<ChevronDown className="h-4 w-4 shrink-0 text-slate-400" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[320px] sm:w-[380px] p-1 shadow-xl">
<DropdownMenuLabel className="px-2.5 py-1.5 text-xs font-semibold text-slate-500">
Select Pickup Hub / Kitchen Location
</DropdownMenuLabel>
<div className="relative mb-1 px-1">
<Search className="pointer-events-none absolute left-3 top-2.5 h-3.5 w-3.5 text-slate-400" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search location or address…"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
className="h-8 w-full rounded-lg border border-slate-200 bg-slate-50 pl-8 pr-2 text-xs text-slate-900 placeholder:text-slate-400 outline-none focus:border-slate-400 focus:bg-white"
/>
</div>
<div className="max-h-60 overflow-y-auto">
{filtered.map((loc) => {
const id = String(loc.tenantlocationid || loc.locationid);
const isSelected = String(selectedLocationId) === id;
return (
<DropdownMenuItem
key={id}
onClick={() => onSelectLocation(id)}
className={cn(
'flex items-start justify-between gap-2 px-2.5 py-2 text-xs cursor-pointer rounded-lg mb-0.5',
isSelected ? 'bg-blue-50 text-blue-900 font-semibold' : 'hover:bg-slate-50'
)}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<p className="truncate text-xs font-semibold text-slate-900">
{locationDisplayName(loc)}
</p>
{loc.isprimary && (
<span className="shrink-0 rounded bg-blue-100/80 px-1.5 py-0.2 text-[9px] font-bold text-blue-800">
PRIMARY
</span>
)}
</div>
{(loc.address || loc.city) && (
<p className="mt-0.5 truncate text-[11px] text-slate-500">
{[loc.address, loc.city, loc.pincode].filter(Boolean).join(' · ')}
</p>
)}
</div>
{isSelected && <Check className="h-4 w-4 text-blue-600 shrink-0 mt-0.5" />}
</DropdownMenuItem>
);
})}
{filtered.length === 0 && (
<p className="py-4 text-center text-xs text-slate-400">No matching locations found</p>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
// Header quick picker variant
return (
<DropdownMenu onOpenChange={(open) => !open && setSearch('')}>
<DropdownMenuTrigger asChild disabled={disabled}>
<button
type="button"
className={cn(
'flex h-9 items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-xs font-semibold text-slate-900 transition-colors shadow-2xs',
'hover:bg-slate-100 hover:border-slate-300 focus:outline-none focus:ring-2 focus:ring-black/10 cursor-pointer',
disabled && 'opacity-60 cursor-not-allowed hover:bg-slate-50',
className
)}
>
<Building2 className="w-4 h-4 text-slate-400 shrink-0" />
<span className="text-[11px] font-semibold text-slate-500 shrink-0">Hub:</span>
<span className="max-w-[180px] sm:max-w-[220px] truncate text-xs font-bold text-slate-900">
{displayLabel}
</span>
<ChevronDown className="w-3.5 h-3.5 text-slate-400 shrink-0 ml-0.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[300px] sm:w-[360px] p-1 shadow-xl">
<DropdownMenuLabel className="px-2.5 py-1.5 text-xs font-semibold text-slate-500">
Switch Pickup Hub / Kitchen
</DropdownMenuLabel>
<div className="relative mb-1 px-1">
<Search className="pointer-events-none absolute left-3 top-2.5 h-3.5 w-3.5 text-slate-400" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search kitchen or hub…"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
className="h-8 w-full rounded-lg border border-slate-200 bg-slate-50 pl-8 pr-2 text-xs text-slate-900 placeholder:text-slate-400 outline-none focus:border-slate-400 focus:bg-white"
/>
</div>
<div className="max-h-60 overflow-y-auto">
{filtered.map((loc) => {
const id = String(loc.tenantlocationid || loc.locationid);
const isSelected = String(selectedLocationId) === id;
return (
<DropdownMenuItem
key={id}
onClick={() => onSelectLocation(id)}
className={cn(
'flex items-start justify-between gap-2 px-2.5 py-2 text-xs cursor-pointer rounded-lg mb-0.5',
isSelected ? 'bg-blue-50 text-blue-900 font-semibold' : 'hover:bg-slate-50'
)}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<p className="truncate text-xs font-semibold text-slate-900">
{locationDisplayName(loc)}
</p>
{loc.isprimary && (
<span className="shrink-0 rounded bg-blue-100/80 px-1.5 py-0.2 text-[9px] font-bold text-blue-800">
PRIMARY
</span>
)}
</div>
{(loc.address || loc.city) && (
<p className="mt-0.5 truncate text-[11px] text-slate-500">
{[loc.address, loc.city].filter(Boolean).join(' · ')}
</p>
)}
</div>
{isSelected && <Check className="h-4 w-4 text-blue-600 shrink-0 mt-0.5" />}
</DropdownMenuItem>
);
})}
{filtered.length === 0 && (
<p className="py-4 text-center text-xs text-slate-400">No matching locations found</p>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -96,6 +96,25 @@ export default function Deliveries() {
const notifyMiler = useNotifyMiler();
const { selectedZone, isAllZones, matchesZone } = useZone();
const riderLookup = useMemo(() => {
const map = new Map();
(riders || []).forEach((r) => {
const name =
r.displayname ||
[r.firstname, r.lastname].filter(Boolean).join(' ').trim() ||
r.name ||
r.authname ||
(r.label ? r.label.split('|')[0].trim() : '');
const phone = r.contactno || r.phone || '';
if (r.userid != null) map.set(String(r.userid), { name, phone });
if (r.milerid != null) map.set(String(r.milerid), { name, phone });
if (r.milerprofileid != null) map.set(String(r.milerprofileid), { name, phone });
if (r.id != null) map.set(String(r.id), { name, phone });
if (r.value != null) map.set(String(r.value), { name, phone });
});
return map;
}, [riders]);
const allRows = useMemo(() => {
return [...(data?.rows || [])].sort((a, b) => {
const ta = parseDoormileTimestamp(a.orderdate || a.assigntime).valueOf() || 0;
@@ -138,15 +157,14 @@ export default function Deliveries() {
}, [batchRows, unmapped]);
const rows = useMemo(() => {
const scoped =
tab === 'all'
? batchRows
: tab === 'other'
? unmapped
: batchRows.filter((row) => {
const status = String(row.orderstatus || '').toLowerCase();
return tab === 'cancelled' ? status === 'cancelled' || status === 'canceled' : status === tab;
});
let scoped = batchRows;
if (tab === 'other') scoped = unmapped;
else if (tab !== 'all') {
scoped = batchRows.filter((row) => {
const status = String(row.orderstatus || '').toLowerCase();
return tab === 'cancelled' ? status === 'cancelled' || status === 'canceled' : status === tab;
});
}
return scoped.filter((row) =>
matchesQuery(
@@ -213,12 +231,24 @@ export default function Deliveries() {
key: 'ridername',
header: 'Rider',
sortable: true,
cell: (row) => (
<div className="min-w-0">
<p className="truncate text-body-sm text-ink-1">{orDash(row.ridername)}</p>
<p className="text-caption text-ink-3">{orDash(row.ridercontact)}</p>
</div>
),
cell: (row) => {
const riderKey = String(row.userid || row.milerprofileid || '');
const info = riderLookup.get(riderKey);
const name =
(info?.name && !info.name.startsWith('Rider #'))
? info.name
: (row.ridername && !row.ridername.startsWith('Rider #'))
? row.ridername
: (info?.name || row.ridername || (row.userid ? `Rider #${row.userid}` : '—'));
const contact = info?.phone || row.ridercontact;
return (
<div className="min-w-0">
<p className="truncate text-body-sm font-semibold text-ink-1">{name}</p>
<p className="text-caption text-ink-3">{orDash(contact)}</p>
</div>
);
},
hideBelow: 'md',
},
{

View File

@@ -21,6 +21,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import dayjs from 'dayjs';
import AddressAutocomplete, { geocodeAddress } from '@/components/doormile/AddressAutocomplete';
import { HubLocationSelect } from '@/components/doormile/HubLocationSelect';
import {
createExpressBooking,
getAdminCustomers,
@@ -31,9 +32,20 @@ import {
import { calculateDrivingDistance, calculateTotalCharge, getLastRouteDurationMin } from '@/lib/distance';
import { OpenToast } from '@/components/third-party/OpenToast';
const SERVICE_OPTIONS = ['Normal', 'Express', 'Scheduled'];
const SERVICE_OPTIONS = ['Normal', 'Fast', 'Superfast'];
const ITEM_CATEGORIES = ['General', 'Food & Beverage', 'Grocery', 'Pharmacy', 'Documents', 'Electronics'];
const WEIGHT_OPTIONS = [
{ value: '1 - 10 kg', label: '1 to 10 kg' },
{ value: '10 - 20 kg', label: '10 to 20 kg' },
{ value: '20 - 30 kg', label: '20 to 30 kg' },
{ value: '30 - 50 kg', label: '30 to 50 kg' },
{ value: '50+ kg', label: '50+ kg' },
{ value: 'custom', label: 'Custom weight…' }
];
const COUNT_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const EMPTY_PARTY = {
firstname: '',
contactno: '',
@@ -46,7 +58,7 @@ const EMPTY_PARTY = {
longitude: ''
};
const EMPTY_PACKAGE = { category: 'General', weight: '', description: '', count: 1 };
const EMPTY_PACKAGE = { category: 'General', weight: '1 - 10 kg', count: 1, isCustomWeight: false, isCustomCount: false };
const buildDefaultPickupSlots = () => {
const arr = [];
@@ -331,21 +343,16 @@ export default function CreateOrder() {
const deliverytime = dayjs(`${pickupDate} ${selectedSlotTime}`, 'YYYY-MM-DD HH:mm').format('YYYY-MM-DD HH:mm:ss');
const finalprice = Number(totalCharge.toFixed(2)) || 0;
const parcels = packages.some((pkg) => pkg.category || pkg.description)
? packages.flatMap((pkg) =>
Array.from({ length: Math.max(1, Number(pkg.count) || 1) }, () => ({
itemcategory: pkg.category || 'General',
itemdescription: [pkg.description, pkg.weight ? `${pkg.weight}kg` : ''].filter(Boolean).join(' · ') || 'Order',
declaredvalue: finalprice
}))
)
: [
{
itemcategory: 'General',
itemdescription: 'Order',
declaredvalue: finalprice
}
];
const parcels = packages.flatMap((pkg) => {
const weightStr = pkg.weight ? (String(pkg.weight).toLowerCase().includes('kg') ? pkg.weight : `${pkg.weight} kg`) : '1 - 10 kg';
const countNum = Math.max(1, Number(pkg.count) || 1);
const desc = `${pkg.category || 'Package'} (${weightStr})`;
return Array.from({ length: countNum }, () => ({
itemcategory: pkg.category || 'General',
itemdescription: desc,
declaredvalue: finalprice
}));
});
const payload = {
tenantid: Number(selectedTenantId),
@@ -362,7 +369,7 @@ export default function CreateOrder() {
deliverylatitude: Number(dropCust.latitude) || 0,
deliverylongitude: Number(dropCust.longitude) || 0,
deliverytime,
service_option: serviceOption,
service_option: SERVICE_OPTIONS.includes(serviceOption) ? serviceOption : 'Normal',
finalprice,
notes: notes || '',
parcels
@@ -380,7 +387,8 @@ export default function CreateOrder() {
OpenToast(res?.message || 'Failed to create order', 'error', 3500);
}
} catch (err) {
OpenToast(err?.message || 'Cannot create order. Please check inputs.', 'error', 3500);
const serverMsg = err?.response?.data?.message || err?.message || 'Cannot create order. Please check inputs.';
OpenToast(serverMsg, 'error', 4000);
} finally {
setIsSubmitting(false);
}
@@ -416,27 +424,13 @@ export default function CreateOrder() {
whole page sideways. */}
<div className="flex flex-wrap items-center gap-3">
{/* Header Hub Quick Picker */}
<div className="flex items-center gap-2 bg-slate-50 border border-slate-200 rounded-lg px-3 py-1.5 shadow-2xs">
<Building2 className="w-4 h-4 text-slate-400 shrink-0" />
<div className="text-[11px] font-semibold text-slate-500 shrink-0">Hub:</div>
<select
value={selectedLocationId}
onChange={(e) => handleSelectLocation(e.target.value)}
disabled={!selectedTenantId || isLocationsLoading}
className="bg-transparent text-xs font-semibold text-slate-900 focus:outline-none cursor-pointer max-w-[220px] truncate"
>
<option value="">{selectedLocation ? selectedLocation.locationname : 'Select Hub Location'}</option>
{sortedLocations.map((loc) => {
const id = String(loc.tenantlocationid || loc.locationid);
return (
<option key={id} value={id}>
{locationDisplayName(loc)}
{loc.isprimary ? ' · PRIMARY' : ''}
</option>
);
})}
</select>
</div>
<HubLocationSelect
locations={tenantLocations}
selectedLocationId={selectedLocationId}
onSelectLocation={handleSelectLocation}
disabled={!selectedTenantId || isLocationsLoading}
placeholder={!selectedTenantId ? 'Choose a client' : 'Select Hub Location'}
/>
<button
type="button"
@@ -489,7 +483,7 @@ export default function CreateOrder() {
setSelectedLocationId('');
setSelectedLocation(null);
}}
className="w-full h-9 px-3 bg-white border border-slate-200 rounded-lg text-xs font-medium text-slate-900 focus:outline-none focus:ring-2 focus:ring-black/10 focus:border-black transition-colors cursor-pointer"
className="w-full h-10 px-3 bg-white border border-slate-200 rounded-xl text-xs font-medium text-slate-900 focus:outline-none focus:ring-2 focus:ring-black/10 focus:border-black transition-colors cursor-pointer"
>
<option value="" disabled>
{isTenantsLoading ? 'Loading tenants…' : 'Select client'}
@@ -506,32 +500,14 @@ export default function CreateOrder() {
<label className="block text-xs font-semibold text-slate-800 mb-1.5">
Business Hub <span className="text-red-500">*</span>
</label>
<select
value={selectedLocationId}
onChange={(e) => handleSelectLocation(e.target.value)}
<HubLocationSelect
variant="field"
locations={tenantLocations}
selectedLocationId={selectedLocationId}
onSelectLocation={handleSelectLocation}
disabled={!selectedTenantId || isLocationsLoading}
className="w-full h-9 px-3 bg-white border border-slate-200 rounded-lg text-xs font-medium text-slate-900 focus:outline-none focus:ring-2 focus:ring-black/10 focus:border-black transition-colors cursor-pointer disabled:bg-slate-50 disabled:opacity-60"
>
<option value="">
{!selectedTenantId
? 'Choose a client first'
: isLocationsLoading
? 'Loading locations…'
: sortedLocations.length === 0
? 'No saved locations for this client'
: 'Select pickup location'}
</option>
{sortedLocations.map((loc) => {
const id = String(loc.tenantlocationid || loc.locationid);
return (
<option key={id} value={id}>
{locationDisplayName(loc)}
{loc.isprimary ? ' · PRIMARY' : ''}
{loc.address ? ` — ${loc.address}` : ''}
</option>
);
})}
</select>
placeholder={!selectedTenantId ? 'Choose a client first' : 'Select pickup location'}
/>
</div>
</div>
</div>
@@ -756,7 +732,7 @@ export default function CreateOrder() {
</div>
{/* Schedule & Packages Card */}
<div className="bg-white rounded-xl border border-slate-200 p-5 shadow-2xs space-y-4">
<div className="bg-white rounded-xl border border-slate-200 p-5 shadow-2xs space-y-5">
<div className="flex items-center gap-2.5 pb-3 border-b border-slate-100">
<div className="w-7 h-7 rounded-lg bg-amber-50 text-amber-600 flex items-center justify-center">
<Clock className="w-4 h-4" />
@@ -775,7 +751,7 @@ export default function CreateOrder() {
min={dayjs().format('YYYY-MM-DD')}
value={pickupDate}
onChange={(e) => setPickupDate(e.target.value)}
className="w-full h-9 px-3 bg-white border border-slate-200 rounded-lg text-xs font-medium text-slate-900 focus:outline-none focus:ring-2 focus:ring-black/10 focus:border-black transition-colors cursor-pointer"
className="w-full h-10 px-3 bg-white border border-slate-200 rounded-xl text-xs font-medium text-slate-900 focus:outline-none focus:ring-2 focus:ring-black/10 focus:border-black transition-colors cursor-pointer"
/>
</div>
@@ -784,7 +760,7 @@ export default function CreateOrder() {
<select
value={selectedSlotTime}
onChange={(e) => setSelectedSlotTime(e.target.value)}
className="w-full h-9 px-3 bg-white border border-slate-200 rounded-lg text-xs font-medium text-slate-900 focus:outline-none focus:ring-2 focus:ring-black/10 focus:border-black transition-colors cursor-pointer"
className="w-full h-10 px-3 bg-white border border-slate-200 rounded-xl text-xs font-medium text-slate-900 focus:outline-none focus:ring-2 focus:ring-black/10 focus:border-black transition-colors cursor-pointer"
>
{buildDefaultPickupSlots().map((slot) => (
<option key={slot.time} value={slot.time}>
@@ -799,7 +775,7 @@ export default function CreateOrder() {
<select
value={serviceOption}
onChange={(e) => setServiceOption(e.target.value)}
className="w-full h-9 px-3 bg-white border border-slate-200 rounded-lg text-xs font-medium text-slate-900 focus:outline-none focus:ring-2 focus:ring-black/10 focus:border-black transition-colors cursor-pointer"
className="w-full h-10 px-3 bg-white border border-slate-200 rounded-xl text-xs font-medium text-slate-900 focus:outline-none focus:ring-2 focus:ring-black/10 focus:border-black transition-colors cursor-pointer"
>
{SERVICE_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
@@ -813,28 +789,41 @@ export default function CreateOrder() {
{/* Parcel Items */}
<div className="pt-2 space-y-3">
<div className="flex items-center justify-between">
<label className="text-xs font-bold text-slate-900">Parcel Items</label>
<div>
<label className="text-xs font-bold text-slate-900">Parcel Items</label>
<p className="text-[11px] text-slate-500">Configure item categories, count, and weight brackets</p>
</div>
<button
type="button"
onClick={handleAddPackage}
className="text-xs font-semibold text-blue-600 hover:text-blue-800 flex items-center gap-1 cursor-pointer"
className="h-8 px-3 border border-slate-200 bg-white hover:bg-slate-50 text-blue-600 text-xs font-semibold rounded-lg flex items-center gap-1.5 transition-colors cursor-pointer shadow-2xs"
>
<Plus className="w-3.5 h-3.5" />
<span>Add Item</span>
</button>
</div>
{/* Column Headers */}
<div className="hidden sm:grid grid-cols-12 gap-3 px-3 text-[11px] font-bold text-slate-500 uppercase tracking-wider">
<div className="col-span-4">Category</div>
<div className="col-span-3">Quantity (Count)</div>
<div className="col-span-4">Weight Bracket</div>
<div className="col-span-1 text-right">Action</div>
</div>
<div className="space-y-2.5">
{packages.map((pkg, idx) => (
<div
key={idx}
className="p-3 bg-slate-50 border border-slate-200/80 rounded-lg grid grid-cols-12 gap-2.5 items-center"
className="p-3 bg-slate-50/80 border border-slate-200 rounded-xl grid grid-cols-12 gap-3 items-center"
>
<div className="col-span-12 sm:col-span-3">
{/* Category */}
<div className="col-span-12 sm:col-span-4">
<label className="block sm:hidden text-[10px] font-bold text-slate-500 mb-1 uppercase">Category</label>
<select
value={pkg.category}
onChange={(e) => handleUpdatePackage(idx, 'category', e.target.value)}
className="w-full h-8 px-2 bg-white border border-slate-200 rounded text-xs font-medium text-slate-800 focus:outline-none focus:border-black cursor-pointer"
className="w-full h-9 px-2.5 bg-white border border-slate-200 rounded-lg text-xs font-medium text-slate-800 focus:outline-none focus:border-black cursor-pointer shadow-2xs"
>
{ITEM_CATEGORIES.map((c) => (
<option key={c} value={c}>
@@ -844,44 +833,116 @@ export default function CreateOrder() {
</select>
</div>
<div className="col-span-12 sm:col-span-5">
<input
type="text"
placeholder="Item description (e.g. Lunch Box, Electronics)"
value={pkg.description}
onChange={(e) => handleUpdatePackage(idx, 'description', e.target.value)}
className="w-full h-8 px-2.5 bg-white border border-slate-200 rounded text-xs font-medium text-slate-800 focus:outline-none focus:border-black"
/>
{/* Quantity / Count */}
<div className="col-span-6 sm:col-span-3">
<label className="block sm:hidden text-[10px] font-bold text-slate-500 mb-1 uppercase">Quantity</label>
{pkg.isCustomCount ? (
<div className="flex items-center gap-1">
<input
type="number"
min="1"
max="999"
placeholder="Qty"
value={pkg.count}
onChange={(e) => handleUpdatePackage(idx, 'count', Math.max(1, Number(e.target.value) || 1))}
className="w-full h-9 px-2.5 bg-white border border-slate-200 rounded-lg text-xs font-semibold text-slate-900 focus:outline-none focus:border-black text-center shadow-2xs"
autoFocus
/>
<button
type="button"
onClick={() => {
handleUpdatePackage(idx, 'isCustomCount', false);
handleUpdatePackage(idx, 'count', 1);
}}
className="text-[10px] text-blue-600 hover:underline shrink-0 px-1"
title="Switch to list"
>
List
</button>
</div>
) : (
<select
value={COUNT_OPTIONS.includes(Number(pkg.count)) ? Number(pkg.count) : 'custom'}
onChange={(e) => {
if (e.target.value === 'custom') {
handleUpdatePackage(idx, 'isCustomCount', true);
handleUpdatePackage(idx, 'count', 11);
} else {
handleUpdatePackage(idx, 'count', Number(e.target.value));
}
}}
className="w-full h-9 px-2.5 bg-white border border-slate-200 rounded-lg text-xs font-semibold text-slate-800 focus:outline-none focus:border-black cursor-pointer shadow-2xs"
>
{COUNT_OPTIONS.map((num) => (
<option key={num} value={num}>
{num} {num === 1 ? 'item' : 'items'}
</option>
))}
<option value="custom">More than 10 (Custom…)</option>
</select>
)}
</div>
<div className="col-span-6 sm:col-span-2">
<input
type="number"
min="1"
placeholder="Qty"
value={pkg.count}
onChange={(e) => handleUpdatePackage(idx, 'count', Number(e.target.value) || 1)}
className="w-full h-8 px-2 bg-white border border-slate-200 rounded text-xs font-medium text-slate-800 focus:outline-none focus:border-black text-center font-mono"
/>
{/* Weight Bracket */}
<div className="col-span-6 sm:col-span-4">
<label className="block sm:hidden text-[10px] font-bold text-slate-500 mb-1 uppercase">Weight</label>
{pkg.isCustomWeight ? (
<div className="flex items-center gap-1">
<input
type="text"
placeholder="e.g. 15 kg"
value={pkg.weight}
onChange={(e) => handleUpdatePackage(idx, 'weight', e.target.value)}
className="w-full h-9 px-2.5 bg-white border border-slate-200 rounded-lg text-xs font-semibold text-slate-900 focus:outline-none focus:border-black shadow-2xs"
autoFocus
/>
<button
type="button"
onClick={() => {
handleUpdatePackage(idx, 'isCustomWeight', false);
handleUpdatePackage(idx, 'weight', '1 - 10 kg');
}}
className="text-[10px] text-blue-600 hover:underline shrink-0 px-1"
title="Switch to brackets"
>
Brackets
</button>
</div>
) : (
<select
value={WEIGHT_OPTIONS.some((w) => w.value === pkg.weight) ? pkg.weight : 'custom'}
onChange={(e) => {
if (e.target.value === 'custom') {
handleUpdatePackage(idx, 'isCustomWeight', true);
handleUpdatePackage(idx, 'weight', '');
} else {
handleUpdatePackage(idx, 'weight', e.target.value);
}
}}
className="w-full h-9 px-2.5 bg-white border border-slate-200 rounded-lg text-xs font-semibold text-slate-800 focus:outline-none focus:border-black cursor-pointer shadow-2xs"
>
{WEIGHT_OPTIONS.map((w) => (
<option key={w.value} value={w.value}>
{w.label}
</option>
))}
</select>
)}
</div>
<div className="col-span-6 sm:col-span-2 flex items-center justify-end gap-1.5">
<input
type="text"
placeholder="Weight (kg)"
value={pkg.weight}
onChange={(e) => handleUpdatePackage(idx, 'weight', e.target.value)}
className="w-16 h-8 px-2 bg-white border border-slate-200 rounded text-xs font-medium text-slate-800 focus:outline-none focus:border-black text-right"
/>
{packages.length > 1 && (
{/* Action */}
<div className="col-span-12 sm:col-span-1 flex items-center justify-end">
{packages.length > 1 ? (
<button
type="button"
onClick={() => handleRemovePackage(idx)}
className="p-1 text-slate-400 hover:text-red-600 rounded transition-colors"
title="Remove parcel"
className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors cursor-pointer"
title="Remove item"
>
<Trash2 className="w-3.5 h-3.5" />
<Trash2 className="w-4 h-4" />
</button>
) : (
<div className="w-8" />
)}
</div>
</div>
@@ -890,13 +951,13 @@ export default function CreateOrder() {
</div>
<div>
<label className="block text-xs font-semibold text-slate-800 mb-1">Dispatch Notes / Gate Instructions</label>
<label className="block text-xs font-semibold text-slate-800 mb-1.5">Dispatch Notes / Gate Instructions</label>
<textarea
rows={2}
placeholder="Gate code, delivery landmarks, call upon arrival instructions…"
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="w-full p-2.5 bg-white border border-slate-200 rounded-lg text-xs font-medium text-slate-900 focus:outline-none focus:ring-2 focus:ring-black/10 focus:border-black transition-colors resize-none"
className="w-full p-3 bg-white border border-slate-200 rounded-xl text-xs font-medium text-slate-900 focus:outline-none focus:ring-2 focus:ring-black/10 focus:border-black transition-colors resize-none shadow-2xs"
/>
</div>
</div>

View File

@@ -23,6 +23,7 @@ import Papa from 'papaparse';
import * as XLSX from 'xlsx';
import AddressAutocomplete, { geocodeAddress } from '@/components/doormile/AddressAutocomplete';
import { HubLocationSelect } from '@/components/doormile/HubLocationSelect';
import {
createExpressBookingBulk,
getAdminCustomers,
@@ -612,32 +613,14 @@ export default function MultipleOrders() {
<label className="block text-xs font-semibold text-slate-800 mb-1.5">
Business Hub <span className="text-red-500">*</span>
</label>
<select
value={selectedLocationId}
onChange={(e) => handleSelectLocation(e.target.value)}
<HubLocationSelect
variant="field"
locations={tenantLocations}
selectedLocationId={selectedLocationId}
onSelectLocation={handleSelectLocation}
disabled={!selectedTenantId || isLocationsLoading}
className="w-full h-9 px-3 bg-white border border-slate-200 rounded-lg text-xs font-medium text-slate-900 focus:outline-none focus:ring-2 focus:ring-black/10 focus:border-black transition-colors cursor-pointer disabled:bg-slate-50 disabled:opacity-60"
>
<option value="">
{!selectedTenantId
? 'Choose a client first'
: isLocationsLoading
? 'Loading locations…'
: sortedLocations.length === 0
? 'No saved locations for this client'
: 'Select pickup location'}
</option>
{sortedLocations.map((loc) => {
const id = String(loc.tenantlocationid || loc.locationid);
return (
<option key={id} value={id}>
{locationDisplayName(loc)}
{loc.isprimary ? ' · PRIMARY' : ''}
{loc.address ? ` — ${loc.address}` : ''}
</option>
);
})}
</select>
placeholder={!selectedTenantId ? 'Choose a client first' : 'Select pickup location'}
/>
</div>
<div className="grid grid-cols-2 gap-3">