feat(bookings): enhance operation request flow and add location selection for first/last mile

This commit is contained in:
Marshal
2026-06-24 00:35:37 +00:00
parent 8ef7641048
commit 5be181aba8
12 changed files with 444 additions and 120 deletions

View File

@@ -70,7 +70,26 @@ export function computeNextStep(
case 'CLEARANCE_READY': case 'CLEARANCE_READY':
return { return {
action: 'PROCEED_TO_OPERATION', 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': case 'OPERATION_REQUESTED':
return { return {

View File

@@ -32,6 +32,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
{} as never, // contractService {} as never, // contractService
{} as never, // filesService {} as never, // filesService
{} as never, // fileUploadSettingsService {} as never, // fileUploadSettingsService
{} as never, // bookingBatchService
bookingsService as never, bookingsService as never,
); );
return { service, bookingsRepository, ruleEngineService }; return { service, bookingsRepository, ruleEngineService };

View File

@@ -43,6 +43,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
{} as never, // contractService {} as never, // contractService
filesService as never, filesService as never,
fileUploadSettingsService as never, fileUploadSettingsService as never,
{} as never, // bookingBatchService
bookingsService as never, bookingsService as never,
); );
return { service, bookingsRepository }; return { service, bookingsRepository };

View File

@@ -7,6 +7,8 @@ import {
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { assertCanApproveBookingStep } from '../../common/freight-permission.util'; 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 { RuleEngineService } from '../rule-engine/rule-engine.service';
import { FilesService } from '../files/files.service'; import { FilesService } from '../files/files.service';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
@@ -30,6 +32,8 @@ export class BookingTransitionService {
private readonly contractService: BookingContractService, private readonly contractService: BookingContractService,
private readonly filesService: FilesService, private readonly filesService: FilesService,
private readonly fileUploadSettingsService: FileUploadSettingsService, private readonly fileUploadSettingsService: FileUploadSettingsService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService,
@Inject(forwardRef(() => BookingsService)) @Inject(forwardRef(() => BookingsService))
private readonly bookingsService: BookingsService, private readonly bookingsService: BookingsService,
) {} ) {}
@@ -769,16 +773,134 @@ export class BookingTransitionService {
return this.bookingsService.findById(bookingId); return this.bookingsService.findById(bookingId);
} }
/** Customer proceeds to operation once clearance is ready → OPERATION_REQUESTED. */ /**
async requestOperation(bookingId: string): Promise<Booking> { * 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<Booking> {
const booking = await this.bookingsService.findById(bookingId); 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, { await this.bookingsRepository.update(bookingId, {
status: 'OPERATION_REQUESTED', status: 'OPERATION_REQUEST_PENDING',
scheduledDate: date,
} as never); } as never);
return this.bookingsService.findById(bookingId); 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<Booking> {
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<Booking> {
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<Booking> {
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<Booking & { async enrichBookingResponse(booking: Booking): Promise<Booking & {
latestChangeRequestNote?: string | null; latestChangeRequestNote?: string | null;
contractSummary?: string | null; contractSummary?: string | null;

View File

@@ -50,6 +50,9 @@ import {
RejectStepDto, RejectStepDto,
RequestChangesDto, RequestChangesDto,
ReviewDocumentDto, ReviewDocumentDto,
RequestOperationDto,
OperationReviewDto,
ConfirmOperationPriceDto,
StaffRejectDto, StaffRejectDto,
} from './dto/request-changes.dto'; } from './dto/request-changes.dto';
import { ContractViewDto } from './dto/contract-view.dto'; import { ContractViewDto } from './dto/contract-view.dto';
@@ -360,10 +363,56 @@ export class BookingsController {
@Post(':id/clearance/proceed') @Post(':id/clearance/proceed')
@ApiOperation({ @ApiOperation({
summary: 'Customer proceeds to operation (CLEARANCE_READY → OPERATION_REQUESTED)', summary:
'Customer requests operation with a schedule day ' +
'(CLEARANCE_READY | OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)',
}) })
async proceedToOperation(@Param('id', ParseUUIDPipe) id: string) { async proceedToOperation(
const booking = await this.transitionService.requestOperation(id); @Param('id', ParseUUIDPipe) id: string,
@Body() dto: RequestOperationDto,
) {
const booking = await this.transitionService.requestOperation(
id,
dto.scheduledDate,
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/operation/review')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({
summary:
'Operations reviews an operation request: ACCEPT (→ batch pool), ' +
'REQUEST_CHANGES (→ back to customer), or ADJUST_PRICE (→ customer re-confirm)',
})
async reviewOperationRequest(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: OperationReviewDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.reviewOperationRequest(
id,
dto.decision,
resolveAuthUserId(user),
{ note: dto.note, amount: dto.amount },
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/operation/confirm-price')
@ApiOperation({
summary:
'Customer confirms or rejects an operations price adjustment ' +
'(OPERATION_PRICE_PENDING_CONFIRM → batch pool | OPERATION_CHANGES_REQUESTED)',
})
async confirmOperationPrice(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ConfirmOperationPriceDto,
) {
const booking = await this.transitionService.confirmOperationPrice(
id,
dto.accept,
);
return this.transitionService.enrichBookingResponse(booking); return this.transitionService.enrichBookingResponse(booking);
} }

View File

@@ -1,5 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { import {
IsBoolean,
IsDateString,
IsIn, IsIn,
IsInt, IsInt,
IsNumber, IsNumber,
@@ -99,3 +101,51 @@ export class ReviewDocumentDto {
@IsString() @IsString()
note?: string; note?: string;
} }
export class RequestOperationDto {
@ApiProperty({
description:
'The schedule day (train departure day) the customer selects for this ' +
'shipment. ISO date — the booking enters the batch pool for this route + day.',
example: '2026-07-15',
})
@IsDateString()
scheduledDate!: string;
}
export class OperationReviewDto {
@ApiProperty({
description:
'The operations decision: ACCEPT enters the batch pool; REQUEST_CHANGES ' +
'returns it to the customer with a note; ADJUST_PRICE sets a new total the ' +
'customer must re-confirm before it proceeds.',
enum: ['ACCEPT', 'REQUEST_CHANGES', 'ADJUST_PRICE'],
})
@IsIn(['ACCEPT', 'REQUEST_CHANGES', 'ADJUST_PRICE'])
decision!: 'ACCEPT' | 'REQUEST_CHANGES' | 'ADJUST_PRICE';
@ApiPropertyOptional({
description: 'Required for REQUEST_CHANGES (what the customer must fix).',
})
@IsOptional()
@IsString()
note?: string;
@ApiPropertyOptional({
description: 'New total price — required for ADJUST_PRICE.',
})
@IsOptional()
@IsNumber()
@Min(0)
amount?: number;
}
export class ConfirmOperationPriceDto {
@ApiProperty({
description:
'true to accept the operations price adjustment and proceed to the ' +
'batch pool; false to reject it (returns to operation changes requested).',
})
@IsBoolean()
accept!: boolean;
}

View File

@@ -48,6 +48,12 @@ export const BOOKING_STATUSES = [
'DOCUMENTS_UNDER_REVIEW', 'DOCUMENTS_UNDER_REVIEW',
'CLEARANCE_READY', 'CLEARANCE_READY',
'OPERATION_REQUESTED', 'OPERATION_REQUESTED',
// Operations review gate: customer picks a schedule day and submits the
// operation request; the operations team reviews capacity/docs/route before
// the booking enters the batch holding pool.
'OPERATION_REQUEST_PENDING',
'OPERATION_CHANGES_REQUESTED',
'OPERATION_PRICE_PENDING_CONFIRM',
] as const; ] as const;
export type BookingStatus = (typeof BOOKING_STATUSES)[number]; export type BookingStatus = (typeof BOOKING_STATUSES)[number];

View File

@@ -216,6 +216,26 @@ export class BookingBatchService implements OnModuleInit {
); );
} }
/**
* Fire-and-forget batch pipeline for a (route, day) directly — used when a
* booking enters the pool without a target train yet (e.g. after the
* operations team accepts an operation request). The booking is already
* FULLY_EXECUTED with its scheduled_date set, so the day-level fill will pick
* it up; this just runs that fill immediately instead of waiting for the cron.
*/
enqueueRouteDayProcessing(
originYardId: string,
destinationYardId: string,
day: string,
): void {
void this.processRouteDay({ originYardId, destinationYardId, day }).catch(
(err) =>
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. */ /** Resolve a schedule's (route, day) group and run the day-level pipeline. */
private async processRouteDayForSchedule(scheduleId: string): Promise<void> { private async processRouteDayForSchedule(scheduleId: string): Promise<void> {
const schedule = await this.trainSchedulesRepository.findById(scheduleId); const schedule = await this.trainSchedulesRepository.findById(scheduleId);

View File

@@ -41,19 +41,27 @@ const PINNED_ZOOM = 14;
const NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"; const NOMINATIM_URL = "https://nominatim.openstreetmap.org/search";
const NOMINATIM_REVERSE_URL = "https://nominatim.openstreetmap.org/reverse"; 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). */ /** Forward-geocode a free-text query to candidate places (free Nominatim API). */
async function searchPlaces(query: string, signal: AbortSignal): Promise<GeocodeResult[]> { async function searchPlaces(query: string, signal: AbortSignal): Promise<GeocodeResult[]> {
const params = new URLSearchParams({ const params = new URLSearchParams({
q: query, q: query,
format: "json", format: "jsonv2",
addressdetails: "0", addressdetails: "0",
limit: "6", limit: "8",
countrycodes: SEARCH_COUNTRYCODES,
}); });
const res = await fetch(`${NOMINATIM_URL}?${params}`, { const res = await fetch(`${NOMINATIM_URL}?${params}`, {
signal, signal,
headers: { Accept: "application/json" }, headers: { Accept: "application/json", "Accept-Language": "en" },
}); });
if (!res.ok) return []; if (!res.ok) return [];
const data = (await res.json()) as Array<{ const data = (await res.json()) as Array<{
@@ -136,15 +144,20 @@ export function LocationPicker({
const hasPin = value.lat != null && value.lng != null; 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(() => { useEffect(() => {
const q = query.trim(); const q = query.trim();
if (q.length < 3) { if (q.length < MIN_QUERY_LEN) {
setResults([]); setResults([]);
setSearching(false); setSearching(false);
return; return;
} }
setSearching(true); setSearching(true);
combobox.openDropdown();
const handle = setTimeout(async () => { const handle = setTimeout(async () => {
abortRef.current?.abort(); abortRef.current?.abort();
const controller = new AbortController(); const controller = new AbortController();
@@ -152,14 +165,16 @@ export function LocationPicker({
try { try {
const found = await searchPlaces(q, controller.signal); const found = await searchPlaces(q, controller.signal);
setResults(found); setResults(found);
} catch { combobox.openDropdown();
setResults([]); } catch (err) {
// Ignore aborts (a newer keystroke superseded this request).
if ((err as Error)?.name !== "AbortError") setResults([]);
} finally { } finally {
setSearching(false); setSearching(false);
} }
}, SEARCH_DEBOUNCE_MS); }, SEARCH_DEBOUNCE_MS);
return () => clearTimeout(handle); return () => clearTimeout(handle);
}, [query]); }, [query, combobox]);
const selectResult = useCallback( const selectResult = useCallback(
(r: GeocodeResult) => { (r: GeocodeResult) => {
@@ -210,18 +225,20 @@ export function LocationPicker({
setQuery(e.currentTarget.value); setQuery(e.currentTarget.value);
combobox.openDropdown(); combobox.openDropdown();
}} }}
onFocus={() => results.length > 0 && combobox.openDropdown()} onFocus={() => {
if (query.trim().length >= MIN_QUERY_LEN) combobox.openDropdown();
}}
/> />
</Combobox.Target> </Combobox.Target>
<Combobox.Dropdown> <Combobox.Dropdown>
<Combobox.Options> <Combobox.Options mah={240} style={{ overflowY: "auto" }}>
{searching ? ( {searching ? (
<Combobox.Empty>Searching</Combobox.Empty> <Combobox.Empty>Searching {query.trim()}</Combobox.Empty>
) : results.length === 0 ? ( ) : results.length === 0 ? (
<Combobox.Empty> <Combobox.Empty>
{query.trim().length < 3 {query.trim().length < MIN_QUERY_LEN
? "Type at least 3 characters" ? `Type at least ${MIN_QUERY_LEN} characters`
: "No matching places"} : "No matching places"}
</Combobox.Empty> </Combobox.Empty>
) : ( ) : (

View File

@@ -1,5 +1,5 @@
import { Box, Text } from "@mantine/core"; import { Box, Group, Text } from "@mantine/core";
import { Banknote, DollarSign } from "lucide-react"; import { Banknote, Check, DollarSign } from "lucide-react";
import { Controller, type Control } from "react-hook-form"; import { Controller, type Control } from "react-hook-form";
import { import {
PAYMENT_CURRENCY_OPTIONS, PAYMENT_CURRENCY_OPTIONS,
@@ -7,14 +7,14 @@ import {
type BookingFormValues, type BookingFormValues,
type PaymentCurrency, type PaymentCurrency,
} from "./schema"; } from "./schema";
import { OptionCard, OptionFieldError, StepLabel } from "./shared"; import { OptionFieldError, StepLabel } from "./shared";
const CURRENCY_ICONS: Record< const CURRENCY_ICONS: Record<
PaymentCurrency, PaymentCurrency,
{ icon: typeof DollarSign; bg: string; color: string } { icon: typeof DollarSign; color: string }
> = { > = {
USD: { icon: DollarSign, bg: "#EEF0FB", color: "#4F46E5" }, USD: { icon: DollarSign, color: "#4F46E5" },
ETB: { icon: Banknote, bg: "#ECF6F1", color: "#0A6F4D" }, ETB: { icon: Banknote, color: "#0A6F4D" },
}; };
export function PaymentCurrencyField({ export function PaymentCurrencyField({
@@ -33,23 +33,75 @@ export function PaymentCurrencyField({
control={control} control={control}
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<div> <div>
<div className="grid gap-4 md:grid-cols-2"> {/* Compact segmented pill selector — lighter than full option cards. */}
<Group
gap={6}
wrap="nowrap"
p={4}
style={{
borderRadius: 12,
background: "#F1F4F7",
border: "1px solid #E6ECF2",
}}
>
{PAYMENT_CURRENCY_OPTIONS.map((option) => { {PAYMENT_CURRENCY_OPTIONS.map((option) => {
const Icon = CURRENCY_ICONS[option.value].icon; const Icon = CURRENCY_ICONS[option.value].icon;
const selected = field.value === option.value;
return ( return (
<OptionCard <button
key={option.value} key={option.value}
selected={field.value === option.value} type="button"
onClick={() => field.onChange(option.value)} onClick={() => field.onChange(option.value)}
icon={<Icon className="h-5 w-5" />} style={{
iconBg={CURRENCY_ICONS[option.value].bg} flex: 1,
iconColor={CURRENCY_ICONS[option.value].color} display: "flex",
title={option.label} alignItems: "center",
description={option.description} 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",
}}
>
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
color: selected
? CURRENCY_ICONS[option.value].color
: "#94A3B8",
}}
>
<Icon className="h-4 w-4" />
</Box>
<Text
fz={14}
fw={selected ? 700 : 600}
c={selected ? "#10202F" : "#64748B"}
>
{option.label}
</Text>
{selected && (
<Check size={15} color={CURRENCY_ICONS[option.value].color} />
)}
</button>
); );
})} })}
</div> </Group>
{/* Description for the active currency, kept subtle. */}
<Text fz={11.5} c="#6B7C8E" mt={8}>
{
PAYMENT_CURRENCY_OPTIONS.find((o) => o.value === field.value)
?.description
}
</Text>
<OptionFieldError error={fieldState.error} /> <OptionFieldError error={fieldState.error} />
</div> </div>
)} )}

View File

@@ -13,6 +13,7 @@ import {
StepLabel, StepLabel,
} from "./shared"; } from "./shared";
import { PaymentCurrencyField } from "./payment-currency-field"; import { PaymentCurrencyField } from "./payment-currency-field";
import { LocationPicker } from "./LocationPicker";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
@@ -45,7 +46,7 @@ export function Step2ServiceType({
useEffect(() => { useEffect(() => {
form.setValue( form.setValue(
"firstMile", "firstMile",
{ enabled: false, pickUpAddress: "" }, { enabled: false, pickUpAddress: "", lat: null, lng: null },
{ shouldValidate: true }, { shouldValidate: true },
); );
}, [includesFirstMile]); }, [includesFirstMile]);
@@ -53,7 +54,7 @@ export function Step2ServiceType({
useEffect(() => { useEffect(() => {
form.setValue( form.setValue(
"lastMile", "lastMile",
{ enabled: false, deliveryAddress: "" }, { enabled: false, deliveryAddress: "", lat: null, lng: null },
{ shouldValidate: true }, { shouldValidate: true },
); );
}, [includesLastMile]); }, [includesLastMile]);
@@ -133,10 +134,39 @@ export function Step2ServiceType({
}} }}
> >
{firstMileEnabled && ( {firstMileEnabled && (
<Text fz={12} c="#6B7C8E" mt="sm"> <Box mt="md">
Youll pick the exact pick-up location on the map in the <Controller
Route step. name="firstMile"
</Text> control={form.control}
render={({ field: mf, fieldState }) => (
<LocationPicker
label="Pick-up location"
placeholder="Search the pick-up address…"
error={
(
fieldState.error as {
pickUpAddress?: { message?: string };
}
)?.pickUpAddress?.message
}
value={{
address: mf.value?.pickUpAddress ?? "",
lat: mf.value?.lat ?? null,
lng: mf.value?.lng ?? null,
}}
onChange={(loc) =>
mf.onChange({
...mf.value,
enabled: true,
pickUpAddress: loc.address,
lat: loc.lat,
lng: loc.lng,
})
}
/>
)}
/>
</Box>
)} )}
</ServiceToggle> </ServiceToggle>
)} )}
@@ -170,10 +200,39 @@ export function Step2ServiceType({
}} }}
> >
{lastMileEnabled && ( {lastMileEnabled && (
<Text fz={12} c="#6B7C8E" mt="sm"> <Box mt="md">
Youll pick the exact delivery location on the map in the <Controller
Route step. name="lastMile"
</Text> control={form.control}
render={({ field: mf, fieldState }) => (
<LocationPicker
label="Delivery location"
placeholder="Search the delivery address…"
error={
(
fieldState.error as {
deliveryAddress?: { message?: string };
}
)?.deliveryAddress?.message
}
value={{
address: mf.value?.deliveryAddress ?? "",
lat: mf.value?.lat ?? null,
lng: mf.value?.lng ?? null,
}}
onChange={(loc) =>
mf.onChange({
...mf.value,
enabled: true,
deliveryAddress: loc.address,
lat: loc.lat,
lng: loc.lng,
})
}
/>
)}
/>
</Box>
)} )}
</ServiceToggle> </ServiceToggle>
)} )}

View File

@@ -300,78 +300,6 @@ export function Step4Route({
</Box> </Box>
)} )}
{(showFirstMile || showLastMile) && (
<Box mt={20}>
<StepLabel>Trucking locations</StepLabel>
<Text fz={12} c="#6B7C8E" mb={12}>
Search for an address or click the map to drop a pin for your
door-to-port and port-to-door trucking.
</Text>
<Stack gap={18}>
{showFirstMile && (
<Controller
name="firstMile"
control={form.control}
render={({ field, fieldState }) => (
<LocationPicker
label="First mile — pick-up location"
placeholder="Search the pick-up address…"
error={
(fieldState.error as { pickUpAddress?: { message?: string } })
?.pickUpAddress?.message
}
value={{
address: field.value?.pickUpAddress ?? "",
lat: field.value?.lat ?? null,
lng: field.value?.lng ?? null,
}}
onChange={(loc) =>
field.onChange({
...field.value,
enabled: true,
pickUpAddress: loc.address,
lat: loc.lat,
lng: loc.lng,
})
}
/>
)}
/>
)}
{showLastMile && (
<Controller
name="lastMile"
control={form.control}
render={({ field, fieldState }) => (
<LocationPicker
label="Last mile — delivery location"
placeholder="Search the delivery address…"
error={
(fieldState.error as { deliveryAddress?: { message?: string } })
?.deliveryAddress?.message
}
value={{
address: field.value?.deliveryAddress ?? "",
lat: field.value?.lat ?? null,
lng: field.value?.lng ?? null,
}}
onChange={(loc) =>
field.onChange({
...field.value,
enabled: true,
deliveryAddress: loc.address,
lat: loc.lat,
lng: loc.lng,
})
}
/>
)}
/>
)}
</Stack>
</Box>
)}
<Divider my={22} color="#EEF2F6" /> <Divider my={22} color="#EEF2F6" />
<StepLabel>Cargo handling</StepLabel> <StepLabel>Cargo handling</StepLabel>