From 5be181aba8b67fc3af893e610eaf5a96e612b30b Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 24 Jun 2026 00:35:37 +0000 Subject: [PATCH 1/3] feat(bookings): enhance operation request flow and add location selection for first/last mile --- .../bookings/booking-next-step.util.ts | 21 ++- .../booking-transition.accept.spec.ts | 1 + .../booking-transition.clearance.spec.ts | 1 + .../bookings/booking-transition.service.ts | 130 +++++++++++++++++- .../modules/bookings/bookings.controller.ts | 55 +++++++- .../bookings/dto/request-changes.dto.ts | 50 +++++++ .../bookings/entities/booking.entity.ts | 6 + .../train-scheduling/booking-batch.service.ts | 20 +++ .../new-booking-form/LocationPicker.tsx | 45 ++++-- .../payment-currency-field.tsx | 84 ++++++++--- .../new-booking-form/step2-service-type.tsx | 79 +++++++++-- .../bookings/new-booking-form/step4-route.tsx | 72 ---------- 12 files changed, 444 insertions(+), 120 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts b/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts index 43e70e5c9..ac6e35b56 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts @@ -70,7 +70,26 @@ export function computeNextStep( case 'CLEARANCE_READY': return { action: 'PROCEED_TO_OPERATION', - description: 'Clearance is ready — proceed to operation', + description: + 'Clearance is ready — pick a schedule day and request operation', + }; + case 'OPERATION_REQUEST_PENDING': + return { + action: 'AWAIT_OPERATION_REVIEW', + description: + 'Operations is reviewing your request (capacity, documents, route)', + }; + case 'OPERATION_CHANGES_REQUESTED': + return { + action: 'RESUBMIT_OPERATION', + description: + 'Operations requested changes — update and resubmit your operation request', + }; + case 'OPERATION_PRICE_PENDING_CONFIRM': + return { + action: 'CONFIRM_OPERATION_PRICE', + description: + 'Operations adjusted the price — confirm the new total to proceed', }; case 'OPERATION_REQUESTED': return { diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts index 6568e3113..676d1b5d4 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts @@ -32,6 +32,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => { {} as never, // contractService {} as never, // filesService {} as never, // fileUploadSettingsService + {} as never, // bookingBatchService bookingsService as never, ); return { service, bookingsRepository, ruleEngineService }; diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts index af81b888a..12ce77d6d 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts @@ -43,6 +43,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => { {} as never, // contractService filesService as never, fileUploadSettingsService as never, + {} as never, // bookingBatchService bookingsService as never, ); return { service, bookingsRepository }; diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 27c5efa87..521095320 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -7,6 +7,8 @@ import { import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { assertCanApproveBookingStep } from '../../common/freight-permission.util'; +import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { eatDay } from '../train-scheduling/batch-window.util'; import { RuleEngineService } from '../rule-engine/rule-engine.service'; import { FilesService } from '../files/files.service'; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; @@ -30,6 +32,8 @@ export class BookingTransitionService { private readonly contractService: BookingContractService, private readonly filesService: FilesService, private readonly fileUploadSettingsService: FileUploadSettingsService, + @Inject(forwardRef(() => BookingBatchService)) + private readonly bookingBatchService: BookingBatchService, @Inject(forwardRef(() => BookingsService)) private readonly bookingsService: BookingsService, ) {} @@ -769,16 +773,134 @@ export class BookingTransitionService { return this.bookingsService.findById(bookingId); } - /** Customer proceeds to operation once clearance is ready → OPERATION_REQUESTED. */ - async requestOperation(bookingId: string): Promise { + /** + * Customer proceeds to operation once clearance is ready. They pick the + * schedule day (the train departure day) for the shipment; the request then + * sits at OPERATION_REQUEST_PENDING for the operations team to review + * (capacity, documents, route) before it enters the batch holding pool. + * + * Allowed from CLEARANCE_READY (first request) and OPERATION_CHANGES_REQUESTED + * (resubmit after the operations team returned it for changes). + */ + async requestOperation( + bookingId: string, + scheduledDate: string, + ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['CLEARANCE_READY']); + assertBookingStatus(booking, ['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED']); + + const date = new Date(scheduledDate); + if (Number.isNaN(date.getTime())) { + throw new BadRequestException('A valid schedule date is required'); + } + await this.bookingsRepository.update(bookingId, { - status: 'OPERATION_REQUESTED', + status: 'OPERATION_REQUEST_PENDING', + scheduledDate: date, } as never); return this.bookingsService.findById(bookingId); } + /** + * Operations team reviews a pending operation request (capacity, documents, + * route). Three outcomes: + * - ACCEPT → booking enters the batch holding pool (FULLY_EXECUTED). + * - REQUEST_CHANGES → returned to the customer with a note to fix and resubmit. + * - ADJUST_PRICE → a new total is set; the customer must re-confirm it + * before the booking can enter the pool. + */ + async reviewOperationRequest( + bookingId: string, + decision: 'ACCEPT' | 'REQUEST_CHANGES' | 'ADJUST_PRICE', + actorId: string, + options: { note?: string; amount?: number } = {}, + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['OPERATION_REQUEST_PENDING']); + + if (decision === 'REQUEST_CHANGES') { + if (!options.note?.trim()) { + throw new BadRequestException( + 'A note is required when requesting changes', + ); + } + await this.bookingsRepository.createReviewNote( + bookingId, + options.note, + 'CHANGES_REQUESTED', + actorId, + ); + await this.bookingsRepository.update(bookingId, { + status: 'OPERATION_CHANGES_REQUESTED', + } as never); + return this.bookingsService.findById(bookingId); + } + + if (decision === 'ADJUST_PRICE') { + if (options.amount == null || options.amount < 0) { + throw new BadRequestException( + 'A non-negative adjusted amount is required to adjust the price', + ); + } + await this.bookingsRepository.update(bookingId, { + adjustedTotalAmount: options.amount, + adjustedByStaffId: actorId, + adjustedAt: new Date(), + adjustmentReason: options.note ?? null, + status: 'OPERATION_PRICE_PENDING_CONFIRM', + } as never); + return this.bookingsService.findById(bookingId); + } + + // ACCEPT — enter the batch holding pool. + return this.acceptOperationRequest(booking); + } + + /** + * Customer re-confirms (or rejects) an operations price adjustment. Accepting + * pushes the booking into the pool; rejecting returns it to the customer as an + * operation change request so they can resubmit or cancel. + */ + async confirmOperationPrice( + bookingId: string, + accept: boolean, + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['OPERATION_PRICE_PENDING_CONFIRM']); + + if (!accept) { + await this.bookingsRepository.update(bookingId, { + status: 'OPERATION_CHANGES_REQUESTED', + } as never); + return this.bookingsService.findById(bookingId); + } + + return this.acceptOperationRequest(booking); + } + + /** + * Move a reviewed operation request into the batch holding pool. The pool query + * (findBatchPoolByRouteDay) keys on FULLY_EXECUTED + scheduled_date, so we set + * those and kick the day-level fill immediately instead of waiting for cron. + */ + private async acceptOperationRequest(booking: Booking): Promise { + const now = new Date(); + await this.bookingsRepository.update(booking.id, { + status: 'FULLY_EXECUTED', + fullyExecutedAt: now, + lockedAt: booking.lockedAt ?? now, + } as never); + + if (booking.scheduledDate) { + this.bookingBatchService.enqueueRouteDayProcessing( + booking.originYardId, + booking.destinationYardId, + eatDay(new Date(booking.scheduledDate)), + ); + } + return this.bookingsService.findById(booking.id); + } + async enrichBookingResponse(booking: Booking): Promise + this.logger.error( + `processRouteDay for ${originYardId}→${destinationYardId} on ${day} failed: ${(err as Error).message}`, + ), + ); + } + /** Resolve a schedule's (route, day) group and run the day-level pipeline. */ private async processRouteDayForSchedule(scheduleId: string): Promise { const schedule = await this.trainSchedulesRepository.findById(scheduleId); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx index 56a89e261..5835b34a4 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx @@ -41,19 +41,27 @@ const PINNED_ZOOM = 14; const NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"; const NOMINATIM_REVERSE_URL = "https://nominatim.openstreetmap.org/reverse"; -const SEARCH_DEBOUNCE_MS = 400; +// Search only fires once the user pauses typing for this long. Slightly longer +// than a keystroke burst so we make one request per pause, not per character — +// and it keeps us within Nominatim's 1 req/s fair-use limit. +const SEARCH_DEBOUNCE_MS = 450; +const MIN_QUERY_LEN = 2; +// Bias geocoding toward the EDR corridor countries so local addresses surface +// first (Nominatim still returns global matches if nothing local fits). +const SEARCH_COUNTRYCODES = "et,dj"; /** Forward-geocode a free-text query to candidate places (free Nominatim API). */ async function searchPlaces(query: string, signal: AbortSignal): Promise { const params = new URLSearchParams({ q: query, - format: "json", + format: "jsonv2", addressdetails: "0", - limit: "6", + limit: "8", + countrycodes: SEARCH_COUNTRYCODES, }); const res = await fetch(`${NOMINATIM_URL}?${params}`, { signal, - headers: { Accept: "application/json" }, + headers: { Accept: "application/json", "Accept-Language": "en" }, }); if (!res.ok) return []; const data = (await res.json()) as Array<{ @@ -136,15 +144,20 @@ export function LocationPicker({ const hasPin = value.lat != null && value.lng != null; - // Debounced forward search as the user types. + // Debounced forward search — fires only after the user stops typing + // (SEARCH_DEBOUNCE_MS of silence), so we make one request per pause rather + // than one per keystroke. The dropdown is kept open the whole time so the + // user sees the "Searching…" state and then the live results for what they + // typed. useEffect(() => { const q = query.trim(); - if (q.length < 3) { + if (q.length < MIN_QUERY_LEN) { setResults([]); setSearching(false); return; } setSearching(true); + combobox.openDropdown(); const handle = setTimeout(async () => { abortRef.current?.abort(); const controller = new AbortController(); @@ -152,14 +165,16 @@ export function LocationPicker({ try { const found = await searchPlaces(q, controller.signal); setResults(found); - } catch { - setResults([]); + combobox.openDropdown(); + } catch (err) { + // Ignore aborts (a newer keystroke superseded this request). + if ((err as Error)?.name !== "AbortError") setResults([]); } finally { setSearching(false); } }, SEARCH_DEBOUNCE_MS); return () => clearTimeout(handle); - }, [query]); + }, [query, combobox]); const selectResult = useCallback( (r: GeocodeResult) => { @@ -210,18 +225,20 @@ export function LocationPicker({ setQuery(e.currentTarget.value); combobox.openDropdown(); }} - onFocus={() => results.length > 0 && combobox.openDropdown()} + onFocus={() => { + if (query.trim().length >= MIN_QUERY_LEN) combobox.openDropdown(); + }} /> - + {searching ? ( - Searching… + Searching “{query.trim()}”… ) : results.length === 0 ? ( - {query.trim().length < 3 - ? "Type at least 3 characters" + {query.trim().length < MIN_QUERY_LEN + ? `Type at least ${MIN_QUERY_LEN} characters` : "No matching places"} ) : ( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/payment-currency-field.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/payment-currency-field.tsx index c11620d4f..d874fb783 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/payment-currency-field.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/payment-currency-field.tsx @@ -1,5 +1,5 @@ -import { Box, Text } from "@mantine/core"; -import { Banknote, DollarSign } from "lucide-react"; +import { Box, Group, Text } from "@mantine/core"; +import { Banknote, Check, DollarSign } from "lucide-react"; import { Controller, type Control } from "react-hook-form"; import { PAYMENT_CURRENCY_OPTIONS, @@ -7,14 +7,14 @@ import { type BookingFormValues, type PaymentCurrency, } from "./schema"; -import { OptionCard, OptionFieldError, StepLabel } from "./shared"; +import { OptionFieldError, StepLabel } from "./shared"; const CURRENCY_ICONS: Record< PaymentCurrency, - { icon: typeof DollarSign; bg: string; color: string } + { icon: typeof DollarSign; color: string } > = { - USD: { icon: DollarSign, bg: "#EEF0FB", color: "#4F46E5" }, - ETB: { icon: Banknote, bg: "#ECF6F1", color: "#0A6F4D" }, + USD: { icon: DollarSign, color: "#4F46E5" }, + ETB: { icon: Banknote, color: "#0A6F4D" }, }; export function PaymentCurrencyField({ @@ -33,23 +33,75 @@ export function PaymentCurrencyField({ control={control} render={({ field, fieldState }) => (
-
+ {/* Compact segmented pill selector — lighter than full option cards. */} + {PAYMENT_CURRENCY_OPTIONS.map((option) => { const Icon = CURRENCY_ICONS[option.value].icon; + const selected = field.value === option.value; return ( - field.onChange(option.value)} - icon={} - iconBg={CURRENCY_ICONS[option.value].bg} - iconColor={CURRENCY_ICONS[option.value].color} - title={option.label} - description={option.description} - /> + style={{ + flex: 1, + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: 8, + padding: "10px 14px", + borderRadius: 9, + cursor: "pointer", + border: "none", + background: selected ? "#fff" : "transparent", + boxShadow: selected + ? "0 1px 3px rgba(16,32,47,0.10)" + : "none", + transition: "all 150ms ease", + }} + > + + + + + {option.label} + + {selected && ( + + )} + ); })} -
+ + {/* Description for the active currency, kept subtle. */} + + { + PAYMENT_CURRENCY_OPTIONS.find((o) => o.value === field.value) + ?.description + } +
)} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx index fd979d570..1f5b92532 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx @@ -13,6 +13,7 @@ import { StepLabel, } from "./shared"; import { PaymentCurrencyField } from "./payment-currency-field"; +import { LocationPicker } from "./LocationPicker"; import type { Freight } from "@edr/types"; @@ -45,7 +46,7 @@ export function Step2ServiceType({ useEffect(() => { form.setValue( "firstMile", - { enabled: false, pickUpAddress: "" }, + { enabled: false, pickUpAddress: "", lat: null, lng: null }, { shouldValidate: true }, ); }, [includesFirstMile]); @@ -53,7 +54,7 @@ export function Step2ServiceType({ useEffect(() => { form.setValue( "lastMile", - { enabled: false, deliveryAddress: "" }, + { enabled: false, deliveryAddress: "", lat: null, lng: null }, { shouldValidate: true }, ); }, [includesLastMile]); @@ -133,10 +134,39 @@ export function Step2ServiceType({ }} > {firstMileEnabled && ( - - You’ll pick the exact pick-up location on the map in the - Route step. - + + ( + + mf.onChange({ + ...mf.value, + enabled: true, + pickUpAddress: loc.address, + lat: loc.lat, + lng: loc.lng, + }) + } + /> + )} + /> + )} )} @@ -170,10 +200,39 @@ export function Step2ServiceType({ }} > {lastMileEnabled && ( - - You’ll pick the exact delivery location on the map in the - Route step. - + + ( + + mf.onChange({ + ...mf.value, + enabled: true, + deliveryAddress: loc.address, + lat: loc.lat, + lng: loc.lng, + }) + } + /> + )} + /> + )} )} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx index 7959a3373..a62dadc7e 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx @@ -300,78 +300,6 @@ export function Step4Route({ )} - {(showFirstMile || showLastMile) && ( - - Trucking locations - - Search for an address or click the map to drop a pin for your - door-to-port and port-to-door trucking. - - - {showFirstMile && ( - ( - - field.onChange({ - ...field.value, - enabled: true, - pickUpAddress: loc.address, - lat: loc.lat, - lng: loc.lng, - }) - } - /> - )} - /> - )} - {showLastMile && ( - ( - - field.onChange({ - ...field.value, - enabled: true, - deliveryAddress: loc.address, - lat: loc.lat, - lng: loc.lng, - }) - } - /> - )} - /> - )} - - - )} - Cargo handling From 7ec06f2ff0efb3f5221a4edcb5cd3acabe9c3920 Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 24 Jun 2026 00:36:01 +0000 Subject: [PATCH 2/3] feat(bookings): enhance operation request flow and add location selection for first/last mile --- .../src/pages/bookings/new-booking-form/step4-route.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx index a62dadc7e..bee0c81ce 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx @@ -30,7 +30,6 @@ import { getRouteDirection, } from "./schema"; import { SelectField, StepCard, StepHeader, StepLabel } from "./shared"; -import { LocationPicker } from "./LocationPicker"; type BookingForm = UseFormReturn< BookingFormInputValues, @@ -50,13 +49,6 @@ export function Step4Route({ const originYard = form.watch("originYard"); const destinationYard = form.watch("destinationYard"); const isGeneralContract = form.watch("bookingType") === "general_contract"; - const serviceTypeId = form.watch("serviceTypeId"); - const firstMileEnabled = form.watch("firstMile.enabled"); - const lastMileEnabled = form.watch("lastMile.enabled"); - - const serviceType = referenceData?.service.find((s) => s.id === serviceTypeId); - const showFirstMile = Boolean(serviceType?.includesFirstMile && firstMileEnabled); - const showLastMile = Boolean(serviceType?.includesLastMile && lastMileEnabled); const { fields: extraRoutes, From 3655e50f2e7fdc6771518f514e656a6fb71eaf13 Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 24 Jun 2026 00:39:43 +0000 Subject: [PATCH 3/3] feat(bookings): refactor location handling for first/last mile and improve geocoding logic --- .../new-booking-form/LocationPicker.tsx | 53 ++++++++++++++++--- .../pages/bookings/new-booking-form/schema.ts | 7 +-- .../new-booking-form/step2-service-type.tsx | 4 +- 3 files changed, 51 insertions(+), 13 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx index 5835b34a4..e9ae9f75f 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx @@ -50,15 +50,19 @@ const MIN_QUERY_LEN = 2; // first (Nominatim still returns global matches if nothing local fits). const SEARCH_COUNTRYCODES = "et,dj"; -/** Forward-geocode a free-text query to candidate places (free Nominatim API). */ -async function searchPlaces(query: string, signal: AbortSignal): Promise { +/** One Nominatim forward-geocode request. `countryCodes` biases to a region. */ +async function nominatimSearch( + query: string, + signal: AbortSignal, + countryCodes?: string, +): Promise { const params = new URLSearchParams({ q: query, format: "jsonv2", addressdetails: "0", limit: "8", - countrycodes: SEARCH_COUNTRYCODES, }); + if (countryCodes) params.set("countrycodes", countryCodes); const res = await fetch(`${NOMINATIM_URL}?${params}`, { signal, headers: { Accept: "application/json", "Accept-Language": "en" }, @@ -76,6 +80,17 @@ async function searchPlaces(query: string, signal: AbortSignal): Promise { + const local = await nominatimSearch(query, signal, SEARCH_COUNTRYCODES); + if (local.length > 0) return local; + return nominatimSearch(query, signal); +} + /** Reverse-geocode a dropped pin to its nearest address. */ async function reverseGeocode(lat: number, lng: number): Promise { const params = new URLSearchParams({ @@ -95,6 +110,21 @@ async function reverseGeocode(lat: number, lng: number): Promise { } } +/** + * Leaflet computes its tile layout from the container size at mount. When the + * map is revealed inside a just-toggled section it can mount before layout + * settles and render grey tiles — invalidating the size on the next frame + * forces a correct redraw. + */ +function InvalidateSizeOnMount() { + const map = useMap(); + useEffect(() => { + const id = setTimeout(() => map.invalidateSize(), 0); + return () => clearTimeout(id); + }, [map]); + return null; +} + /** Recenters the map imperatively when the pinned coordinate changes. */ function MapRecenter({ lat, lng }: { lat: number | null; lng: number | null }) { const map = useMap(); @@ -158,22 +188,28 @@ export function LocationPicker({ } setSearching(true); combobox.openDropdown(); + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; const handle = setTimeout(async () => { - abortRef.current?.abort(); - const controller = new AbortController(); - abortRef.current = controller; try { const found = await searchPlaces(q, controller.signal); + if (controller.signal.aborted) return; setResults(found); combobox.openDropdown(); } catch (err) { // Ignore aborts (a newer keystroke superseded this request). if ((err as Error)?.name !== "AbortError") setResults([]); } finally { - setSearching(false); + if (!controller.signal.aborted) setSearching(false); } }, SEARCH_DEBOUNCE_MS); - return () => clearTimeout(handle); + // Cancel both the pending debounce AND any in-flight request when the query + // changes, so a stale response can't overwrite newer results. + return () => { + clearTimeout(handle); + controller.abort(); + }; }, [query, combobox]); const selectResult = useCallback( @@ -277,6 +313,7 @@ export function LocationPicker({ attribution='© OpenStreetMap contributors' url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" /> + {hasPin && ( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts index 4f6a1647b..5bac52a36 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts @@ -334,6 +334,10 @@ export const stepFields: Record>> = { "equipmentReturn", "customsClearingEnabled", "customsClearingAgent", + // First/last-mile pickup & delivery locations are captured inline in the + // service step, right under each trucking toggle. + "firstMile", + "lastMile", ], 3: ["cargoType", "cargoWeight", "cargoTypePath", "containers"], 4: [ @@ -341,9 +345,6 @@ export const stepFields: Record>> = { "destinationYard", "primaryRouteQuantity", "extraRoutes", - // First/last-mile pickup & delivery locations are captured here on the map. - "firstMile", - "lastMile", "isHazardous", "isRefrigerated", ], diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx index 1f5b92532..e326f84e7 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx @@ -124,7 +124,7 @@ export function Step2ServiceType({ onChange={(value) => { field.onChange(value); if (!value) { - // Clear the captured pick-up location (set on the Route step). + // Toggling off clears the captured pick-up location below. form.setValue( "firstMile", { enabled: false, pickUpAddress: "", lat: null, lng: null }, @@ -187,7 +187,7 @@ export function Step2ServiceType({ onChange={(value) => { field.onChange(value); if (!value) { - // Clear the captured delivery location (set on the Route step). + // Toggling off clears the captured delivery location below. form.setValue( "lastMile", { enabled: false, deliveryAddress: "", lat: null, lng: null },