mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge branch 'freight_feature/profile' of github.com:Tria-plc/edr-platform into freight_feature/profile
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<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);
|
||||
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<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 & {
|
||||
latestChangeRequestNote?: string | null;
|
||||
contractSummary?: string | null;
|
||||
|
||||
@@ -50,6 +50,9 @@ import {
|
||||
RejectStepDto,
|
||||
RequestChangesDto,
|
||||
ReviewDocumentDto,
|
||||
RequestOperationDto,
|
||||
OperationReviewDto,
|
||||
ConfirmOperationPriceDto,
|
||||
StaffRejectDto,
|
||||
} from './dto/request-changes.dto';
|
||||
import { ContractViewDto } from './dto/contract-view.dto';
|
||||
@@ -360,10 +363,56 @@ export class BookingsController {
|
||||
|
||||
@Post(':id/clearance/proceed')
|
||||
@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) {
|
||||
const booking = await this.transitionService.requestOperation(id);
|
||||
async proceedToOperation(
|
||||
@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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
@@ -99,3 +101,51 @@ export class ReviewDocumentDto {
|
||||
@IsString()
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,12 @@ export const BOOKING_STATUSES = [
|
||||
'DOCUMENTS_UNDER_REVIEW',
|
||||
'CLEARANCE_READY',
|
||||
'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;
|
||||
|
||||
export type BookingStatus = (typeof BOOKING_STATUSES)[number];
|
||||
|
||||
@@ -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. */
|
||||
private async processRouteDayForSchedule(scheduleId: string): Promise<void> {
|
||||
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
|
||||
|
||||
@@ -41,19 +41,31 @@ 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<GeocodeResult[]> {
|
||||
/** One Nominatim forward-geocode request. `countryCodes` biases to a region. */
|
||||
async function nominatimSearch(
|
||||
query: string,
|
||||
signal: AbortSignal,
|
||||
countryCodes?: string,
|
||||
): Promise<GeocodeResult[]> {
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
format: "json",
|
||||
format: "jsonv2",
|
||||
addressdetails: "0",
|
||||
limit: "6",
|
||||
limit: "8",
|
||||
});
|
||||
if (countryCodes) params.set("countrycodes", 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<{
|
||||
@@ -68,6 +80,17 @@ async function searchPlaces(query: string, signal: AbortSignal): Promise<Geocode
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward-geocode a free-text query. We try the EDR corridor (ET/DJ) first so
|
||||
* local addresses rank highest, then fall back to a global search when nothing
|
||||
* local matches — so the field never looks "broken" for an out-of-region query.
|
||||
*/
|
||||
async function searchPlaces(query: string, signal: AbortSignal): Promise<GeocodeResult[]> {
|
||||
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<string> {
|
||||
const params = new URLSearchParams({
|
||||
@@ -87,6 +110,21 @@ async function reverseGeocode(lat: number, lng: number): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
@@ -136,30 +174,43 @@ 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();
|
||||
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);
|
||||
} catch {
|
||||
setResults([]);
|
||||
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);
|
||||
}, [query]);
|
||||
// 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(
|
||||
(r: GeocodeResult) => {
|
||||
@@ -210,18 +261,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();
|
||||
}}
|
||||
/>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options>
|
||||
<Combobox.Options mah={240} style={{ overflowY: "auto" }}>
|
||||
{searching ? (
|
||||
<Combobox.Empty>Searching…</Combobox.Empty>
|
||||
<Combobox.Empty>Searching “{query.trim()}”…</Combobox.Empty>
|
||||
) : results.length === 0 ? (
|
||||
<Combobox.Empty>
|
||||
{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"}
|
||||
</Combobox.Empty>
|
||||
) : (
|
||||
@@ -260,6 +313,7 @@ export function LocationPicker({
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<InvalidateSizeOnMount />
|
||||
<ClickToPin onPick={handlePin} />
|
||||
<MapRecenter lat={value.lat} lng={value.lng} />
|
||||
{hasPin && (
|
||||
|
||||
@@ -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 }) => (
|
||||
<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) => {
|
||||
const Icon = CURRENCY_ICONS[option.value].icon;
|
||||
const selected = field.value === option.value;
|
||||
return (
|
||||
<OptionCard
|
||||
<button
|
||||
key={option.value}
|
||||
selected={field.value === option.value}
|
||||
type="button"
|
||||
onClick={() => field.onChange(option.value)}
|
||||
icon={<Icon className="h-5 w-5" />}
|
||||
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",
|
||||
}}
|
||||
>
|
||||
<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} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -334,6 +334,10 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
|
||||
"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<number, Array<Path<BookingFormValues>>> = {
|
||||
"destinationYard",
|
||||
"primaryRouteQuantity",
|
||||
"extraRoutes",
|
||||
// First/last-mile pickup & delivery locations are captured here on the map.
|
||||
"firstMile",
|
||||
"lastMile",
|
||||
"isHazardous",
|
||||
"isRefrigerated",
|
||||
],
|
||||
|
||||
@@ -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]);
|
||||
@@ -123,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 },
|
||||
@@ -133,10 +134,39 @@ export function Step2ServiceType({
|
||||
}}
|
||||
>
|
||||
{firstMileEnabled && (
|
||||
<Text fz={12} c="#6B7C8E" mt="sm">
|
||||
You’ll pick the exact pick-up location on the map in the
|
||||
Route step.
|
||||
</Text>
|
||||
<Box mt="md">
|
||||
<Controller
|
||||
name="firstMile"
|
||||
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>
|
||||
)}
|
||||
@@ -157,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 },
|
||||
@@ -170,10 +200,39 @@ export function Step2ServiceType({
|
||||
}}
|
||||
>
|
||||
{lastMileEnabled && (
|
||||
<Text fz={12} c="#6B7C8E" mt="sm">
|
||||
You’ll pick the exact delivery location on the map in the
|
||||
Route step.
|
||||
</Text>
|
||||
<Box mt="md">
|
||||
<Controller
|
||||
name="lastMile"
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
@@ -300,78 +292,6 @@ export function Step4Route({
|
||||
</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" />
|
||||
|
||||
<StepLabel>Cargo handling</StepLabel>
|
||||
|
||||
Reference in New Issue
Block a user