feat(bookings): add contract validity window and customs clearing features

- Introduced contract validity days, valid from and valid until fields in the booking model.
- Updated booking acceptance logic to enforce validity window constraints.
- Added customs clearing agent and first/last mile pickup/delivery coordinates to the booking model.
- Implemented LocationPicker component for address selection with map integration using Leaflet.
- Enhanced booking review step to display customs clearing agent details.
- Updated API and DTOs to accommodate new booking fields.
- Added migration scripts for database schema changes.
- Implemented tests for booking acceptance and DTO transformations.
This commit is contained in:
Marshal
2026-06-24 00:21:01 +00:00
parent 07cd7dc111
commit 8ef7641048
31 changed files with 1251 additions and 226 deletions

View File

@@ -39,7 +39,16 @@ async function bootstrap() {
});
app.setGlobalPrefix("api");
app.useGlobalPipes(createValidationPipe());
// enableImplicitConversion is OFF: class-transformer's implicit boolean
// coercion turns any non-empty multipart/form-data string (including the
// literal "false") into `true`, silently corrupting flags like isHazardous
// and isGovernment. With it off, only explicit @Transform/@Type decorators
// coerce values — every numeric/boolean DTO field in this API already has one.
app.useGlobalPipes(
createValidationPipe({
transformOptions: { enableImplicitConversion: false },
}),
);
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new ResponseTransformInterceptor());

View File

@@ -0,0 +1,37 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Contract validity window. When the backoffice accepts a price-confirmed
* booking, staff define how many days the contract stays valid. The window runs
* from the accept moment (valid_from) through valid_from + N days (valid_until).
* Outside that window the contract is considered expired.
*/
export class AddContractValidityWindow1820000000005
implements MigrationInterface
{
name = 'AddContractValidityWindow1820000000005';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS contract_validity_days integer;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS contract_valid_from timestamptz;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS contract_valid_until timestamptz;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS contract_valid_until;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS contract_valid_from;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS contract_validity_days;`,
);
}
}

View File

@@ -0,0 +1,41 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Booking now captures:
* - customs clearing as an explicit flag + the customs clearing agent name
* (shown when the service includes customs), and
* - first/last-mile pickup & delivery coordinates (lat/lng) alongside the
* existing address text, so the map picker can store and restore the pin.
*
* Shipping line is no longer collected from the booking form; the column stays
* for historical data and the (now dormant) shipping-line pricing trigger.
*/
export class AddCustomsAgentAndMileCoordinates1820000000006
implements MigrationInterface
{
name = 'AddCustomsAgentAndMileCoordinates1820000000006';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS first_mile_pickup_lat numeric(10,7) NULL,
ADD COLUMN IF NOT EXISTS first_mile_pickup_lng numeric(10,7) NULL,
ADD COLUMN IF NOT EXISTS last_mile_delivery_lat numeric(10,7) NULL,
ADD COLUMN IF NOT EXISTS last_mile_delivery_lng numeric(10,7) NULL,
ADD COLUMN IF NOT EXISTS customs_clearing_enabled boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS customs_clearing_agent varchar(200) NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS customs_clearing_agent,
DROP COLUMN IF EXISTS customs_clearing_enabled,
DROP COLUMN IF EXISTS last_mile_delivery_lng,
DROP COLUMN IF EXISTS last_mile_delivery_lat,
DROP COLUMN IF EXISTS first_mile_pickup_lng,
DROP COLUMN IF EXISTS first_mile_pickup_lat;
`);
}
}

View File

@@ -0,0 +1,84 @@
import { BadRequestException } from '@nestjs/common';
import { BookingTransitionService } from './booking-transition.service';
/**
* Focused tests for the contract validity window set at the accept step.
* The backoffice must supply a number of days; the window runs from the accept
* moment through accept + N days.
*/
describe('BookingTransitionService — acceptIntake validity window', () => {
const booking = {
id: 'b-1',
status: 'SUBMITTED',
freightType: 'CONTAINER',
cargoTypeId: null,
};
function makeService() {
const bookingsRepository = {
update: jest.fn().mockResolvedValue({ id: 'b-1' }),
};
const bookingsService = {
findById: jest.fn().mockResolvedValue(booking),
};
const ruleEngineService = {
instantiateApprovalSteps: jest.fn().mockResolvedValue([]),
};
const service = new BookingTransitionService(
bookingsRepository as never,
ruleEngineService as never,
{} as never, // pricingService
{} as never, // contractService
{} as never, // filesService
{} as never, // fileUploadSettingsService
bookingsService as never,
);
return { service, bookingsRepository, ruleEngineService };
}
it('rejects accept when validity days is missing or non-positive', async () => {
const { service } = makeService();
await expect(
service.acceptIntake('b-1', 'staff-1', 0),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
service.acceptIntake('b-1', 'staff-1', -5),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
service.acceptIntake('b-1', 'staff-1', 1.5),
).rejects.toBeInstanceOf(BadRequestException);
});
it('sets a validity window of validFrom..validFrom + N days', async () => {
const { service, bookingsRepository } = makeService();
await service.acceptIntake('b-1', 'staff-1', 10);
expect(bookingsRepository.update).toHaveBeenCalledTimes(1);
const [id, updates] = bookingsRepository.update.mock.calls[0];
expect(id).toBe('b-1');
expect(updates).toMatchObject({
status: 'PENDING_APPROVAL',
approvedByStaffId: 'staff-1',
contractValidityDays: 10,
});
const from = updates.contractValidFrom as Date;
const until = updates.contractValidUntil as Date;
const diffDays = Math.round(
(until.getTime() - from.getTime()) / (1000 * 60 * 60 * 24),
);
expect(diffDays).toBe(10);
// The accept timestamp and the validity start are the same moment.
expect((updates.approvedByStaffAt as Date).getTime()).toBe(from.getTime());
});
it('instantiates the approval chain when accepting', async () => {
const { service, ruleEngineService } = makeService();
await service.acceptIntake('b-1', 'staff-1', 30);
expect(ruleEngineService.instantiateApprovalSteps).toHaveBeenCalledWith(
'b-1',
expect.objectContaining({ freightType: 'CONTAINER' }),
);
});
});

View File

@@ -198,13 +198,31 @@ export class BookingTransitionService {
});
}
async acceptIntake(bookingId: string, actorId: string): Promise<Booking> {
async acceptIntake(
bookingId: string,
actorId: string,
validityDays: number,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
// Only SUBMITTED bookings are acceptable. A booking that still needs
// consolidation sits in PENDING_CONSOLIDATION (resolved at submit time) and
// is therefore never offered for accept until a partner moves it to SUBMITTED.
assertBookingStatus(booking, ['SUBMITTED']);
// The backoffice must define how long the accepted contract stays valid.
// Without a window the contract has no end date and cannot be relied on, so
// accept is blocked until a positive number of days is supplied.
if (!Number.isInteger(validityDays) || validityDays < 1) {
throw new BadRequestException(
'A contract validity (in days) is required to accept this booking.',
);
}
// Validity runs from the accept moment through accept + N days.
const validFrom = new Date();
const validUntil = new Date(validFrom);
validUntil.setDate(validUntil.getDate() + validityDays);
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
freightType: booking.freightType as 'CONTAINER' | 'BULK',
cargoTypeId: booking.cargoTypeId,
@@ -213,7 +231,10 @@ export class BookingTransitionService {
const updated = await this.bookingsRepository.update(bookingId, {
status: 'PENDING_APPROVAL',
approvedByStaffId: actorId,
approvedByStaffAt: new Date(),
approvedByStaffAt: validFrom,
contractValidityDays: validityDays,
contractValidFrom: validFrom,
contractValidUntil: validUntil,
} as never);
return this.bookingsService.findById(updated!.id);
}

View File

@@ -42,6 +42,7 @@ import { FilterBookingDto } from './dto/filter-booking.dto';
import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
import {
AcceptIntakeDto,
AdjustPriceDto,
ApproveStepDto,
CancelBookingDto,
@@ -428,14 +429,19 @@ export class BookingsController {
@Post(':id/staff/accept')
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
@ApiOperation({ summary: 'Staff accept intake → start approval chain' })
@ApiOperation({
summary:
'Staff accept intake → set contract validity window + start approval chain',
})
async acceptIntake(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AcceptIntakeDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.acceptIntake(
id,
resolveAuthUserId(user),
dto.validityDays,
);
return this.transitionService.enrichBookingResponse(booking);
}

View File

@@ -406,7 +406,13 @@ export class BookingsService {
previousContractId: dto.previousContractId,
serviceTypeId: dto.serviceTypeId,
firstMilePickupAddress: dto.firstMilePickupAddress,
firstMilePickupLat: dto.firstMilePickupLat ?? null,
firstMilePickupLng: dto.firstMilePickupLng ?? null,
lastMileDeliveryAddress: dto.lastMileDeliveryAddress,
lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null,
lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null,
customsClearingEnabled: dto.customsClearingEnabled ?? false,
customsClearingAgent: dto.customsClearingAgent ?? null,
equipmentReturn: dto.equipmentReturn,
originYardId: dto.originYardId,
destinationYardId: dto.destinationYardId,

View File

@@ -0,0 +1,47 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { CreateBookingDto } from './create-booking.dto';
/**
* Boolean flags arrive as STRINGS over multipart/form-data ("true" / "false").
* The global freight ValidationPipe runs with enableImplicitConversion = false,
* so only the explicit @Transform on each flag coerces it. This pins that the
* literal string "false" maps to boolean `false` — class-transformer's implicit
* boolean coercion would otherwise turn any non-empty string (including "false")
* into `true`, silently flagging non-hazardous bookings as hazardous.
*/
describe('CreateBookingDto — boolean coercion from multipart strings', () => {
// Mirror the production pipe: explicit transforms only, no implicit coercion.
const toDto = (plain: Record<string, unknown>) =>
plainToInstance(CreateBookingDto, plain, {
enableImplicitConversion: false,
}) as unknown as CreateBookingDto;
it('maps the string "false" to boolean false for every flag', () => {
const dto = toDto({
isHazardous: 'false',
isGovernment: 'false',
customsClearingEnabled: 'false',
});
expect(dto.isHazardous).toBe(false);
expect(dto.isGovernment).toBe(false);
expect(dto.customsClearingEnabled).toBe(false);
});
it('maps the string "true" to boolean true for every flag', () => {
const dto = toDto({
isHazardous: 'true',
isGovernment: 'true',
customsClearingEnabled: 'true',
});
expect(dto.isHazardous).toBe(true);
expect(dto.isGovernment).toBe(true);
expect(dto.customsClearingEnabled).toBe(true);
});
it('still coerces numeric form strings to numbers', () => {
const dto = toDto({ cargoTotalWeightVgm: '12.5' });
expect(dto.cargoTotalWeightVgm).toBe(12.5);
expect(typeof dto.cargoTotalWeightVgm).toBe('number');
});
});

View File

@@ -11,6 +11,8 @@ import {
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
MinLength,
Validate,
@@ -168,11 +170,55 @@ export class CreateBookingDto {
@IsString()
firstMilePickupAddress?: string;
@ApiPropertyOptional({ description: 'First-mile pickup latitude (-90..90)' })
@IsOptional()
@IsNumber()
@Min(-90)
@Max(90)
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
firstMilePickupLat?: number;
@ApiPropertyOptional({ description: 'First-mile pickup longitude (-180..180)' })
@IsOptional()
@IsNumber()
@Min(-180)
@Max(180)
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
firstMilePickupLng?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
lastMileDeliveryAddress?: string;
@ApiPropertyOptional({ description: 'Last-mile delivery latitude (-90..90)' })
@IsOptional()
@IsNumber()
@Min(-90)
@Max(90)
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
lastMileDeliveryLat?: number;
@ApiPropertyOptional({ description: 'Last-mile delivery longitude (-180..180)' })
@IsOptional()
@IsNumber()
@Min(-180)
@Max(180)
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
lastMileDeliveryLng?: number;
@ApiPropertyOptional({ description: 'Whether EDR handles customs clearance' })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === 'true' || value === true)
customsClearingEnabled?: boolean;
@ApiPropertyOptional({ maxLength: 200, description: 'Customs clearing agent name (when customs is enabled)' })
@IsOptional()
@IsString()
@MaxLength(200)
customsClearingAgent?: string;
@ApiProperty({ enum: EQUIPMENT_RETURNS })
@IsIn([...EQUIPMENT_RETURNS])
equipmentReturn!: string;

View File

@@ -1,5 +1,14 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsNumber, IsOptional, IsString, Min, MinLength } from 'class-validator';
import {
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
Max,
Min,
MinLength,
} from 'class-validator';
export class RequestChangesDto {
@ApiProperty({ description: 'Staff note explaining what the customer must fix' })
@@ -8,6 +17,21 @@ export class RequestChangesDto {
note!: string;
}
export class AcceptIntakeDto {
@ApiProperty({
description:
'How many days the contract stays valid, counted from the accept date. ' +
'The contract is valid from now through now + validityDays.',
minimum: 1,
maximum: 365,
example: 30,
})
@IsInt()
@Min(1)
@Max(365)
validityDays!: number;
}
export class StaffRejectDto {
@ApiProperty()
@IsString()

View File

@@ -177,6 +177,21 @@ export class Booking extends BaseEntity {
@Column({ name: 'adjustment_reason', type: 'text', nullable: true })
adjustmentReason?: string | null;
/**
* Contract validity window, set by the backoffice at the accept step. The
* staff enter a number of days; the contract is valid from contractValidFrom
* (the accept moment) through contractValidUntil (validFrom + N days). Outside
* this window the contract is expired and the booking cannot proceed.
*/
@Column({ name: 'contract_validity_days', type: 'int', nullable: true })
contractValidityDays?: number | null;
@Column({ name: 'contract_valid_from', type: 'timestamptz', nullable: true })
contractValidFrom?: Date | null;
@Column({ name: 'contract_valid_until', type: 'timestamptz', nullable: true })
contractValidUntil?: Date | null;
@Column({ name: 'payment_status', type: 'varchar', length: 20, default: 'PENDING' })
paymentStatus!: string;
@@ -200,9 +215,27 @@ export class Booking extends BaseEntity {
@Column({ name: 'first_mile_pickup_address', type: 'text', nullable: true })
firstMilePickupAddress?: string | null;
@Column({ name: 'first_mile_pickup_lat', type: 'numeric', precision: 10, scale: 7, nullable: true })
firstMilePickupLat?: number | null;
@Column({ name: 'first_mile_pickup_lng', type: 'numeric', precision: 10, scale: 7, nullable: true })
firstMilePickupLng?: number | null;
@Column({ name: 'last_mile_delivery_address', type: 'text', nullable: true })
lastMileDeliveryAddress?: string | null;
@Column({ name: 'last_mile_delivery_lat', type: 'numeric', precision: 10, scale: 7, nullable: true })
lastMileDeliveryLat?: number | null;
@Column({ name: 'last_mile_delivery_lng', type: 'numeric', precision: 10, scale: 7, nullable: true })
lastMileDeliveryLng?: number | null;
@Column({ name: 'customs_clearing_enabled', type: 'boolean', default: false })
customsClearingEnabled!: boolean;
@Column({ name: 'customs_clearing_agent', type: 'varchar', length: 200, nullable: true })
customsClearingAgent?: string | null;
@Column({ name: 'equipment_return', type: 'varchar', length: 20 })
equipmentReturn!: string;

View File

@@ -8,10 +8,22 @@ import {
Button,
Textarea,
FileInput,
NumberInput,
} from "@mantine/core";
import type { BookingActionDef } from "@/features/bookings/booking-actions.config";
/** Today + `days`, formatted as a readable date for the validity preview. */
function validUntilLabel(days: number): string {
const until = new Date();
until.setDate(until.getDate() + days);
return until.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
interface BookingConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -46,8 +58,14 @@ export function BookingConfirmDialog({
const Icon = action.icon;
const needsTextInput = action.input === "note" || action.input === "reason";
const needsFileInput = action.input === "file";
const needsDaysInput = action.input === "days";
const daysValue = Number(inputValue.trim());
const daysValid =
Number.isInteger(daysValue) && daysValue >= 1 && daysValue <= 365;
const inputMissing =
(needsTextInput && !inputValue.trim()) || (needsFileInput && !selectedFile);
(needsTextInput && !inputValue.trim()) ||
(needsFileInput && !selectedFile) ||
(needsDaysInput && !daysValid);
const isDestructive = action.variant === "destructive";
const accent = isDestructive ? "red" : "edr-green";
@@ -129,6 +147,27 @@ export function BookingConfirmDialog({
clearable
/>
)}
{needsDaysInput && (
<Stack gap={4}>
<NumberInput
label={action.inputLabel ?? "Contract validity (days)"}
withAsterisk
min={1}
max={365}
clampBehavior="strict"
allowDecimal={false}
allowNegative={false}
placeholder={action.inputPlaceholder ?? "e.g. 30"}
value={inputValue === "" ? "" : Number(inputValue)}
onChange={(value) => onInputChange(value === "" ? "" : String(value))}
/>
<Text size="xs" c="dimmed">
{daysValid
? `Contract valid from today until ${validUntilLabel(daysValue)} (${daysValue} day${daysValue === 1 ? "" : "s"}).`
: "Enter a whole number of days between 1 and 365."}
</Text>
</Stack>
)}
{extra}
</Stack>

View File

@@ -9,6 +9,12 @@ import {
import { useAuth } from "@/auth/useAuth";
import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings";
/** A contract validity window must be a whole number of days, 1365. */
function isValidValidityDays(value: string): boolean {
const days = Number(value.trim());
return Number.isInteger(days) && days >= 1 && days <= 365;
}
export function useBookingActionDialog(
bookingId: string,
context: BookingActionContext,
@@ -59,9 +65,12 @@ export function useBookingActionDialog(
const onSuccess = () => closeDialog();
switch (pendingAction.id) {
case "accept":
mutations.staffAccept.mutate(undefined, { onSuccess });
case "accept": {
const days = Number(inputValue.trim());
if (!Number.isInteger(days) || days < 1 || days > 365) return;
mutations.staffAccept.mutate(days, { onSuccess });
break;
}
case "requestChanges":
mutations.requestChanges.mutate(inputValue.trim(), { onSuccess });
break;
@@ -116,7 +125,8 @@ export function useBookingActionDialog(
!getNextPendingApprovalStep(mergedContext.approvalSteps)) ||
(pendingAction?.input === "file" && !selectedFile) ||
(pendingAction?.input === "reason" && !inputValue.trim()) ||
(pendingAction?.input === "note" && !inputValue.trim());
(pendingAction?.input === "note" && !inputValue.trim()) ||
(pendingAction?.input === "days" && !isValidValidityDays(inputValue));
return {
actions,

View File

@@ -36,7 +36,7 @@ export type BookingActionId =
| "complete"
| "cancel";
export type BookingActionInputKind = "note" | "reason" | "file";
export type BookingActionInputKind = "note" | "reason" | "file" | "days";
export interface BookingActionDef {
id: BookingActionId;
@@ -115,10 +115,13 @@ const SUBMITTED_ACTIONS: BookingActionDef[] = [
description: "Start the formal approval chain",
confirmTitle: "Accept submission?",
confirmDescription:
"The booking moves to pending approval and approval steps are created from the rule engine.",
"Set how long the contract stays valid, then the booking moves to pending approval and approval steps are created from the rule engine.",
variant: "default",
icon: ShieldCheck,
primary: true,
input: "days",
inputLabel: "Contract validity (days)",
inputPlaceholder: "e.g. 30",
},
{
id: "requestChanges",

View File

@@ -41,7 +41,8 @@ export function useBookingMutations(bookingId: string) {
};
const staffAccept = useMutation({
mutationFn: () => api.bookings.staffAccept.call({ id: bookingId }),
mutationFn: (validityDays: number) =>
api.bookings.staffAccept.call({ id: bookingId, validityDays }),
onSuccess: (data) => onSuccess(data, "Booking accepted for approval"),
onError: () => toast.error("Failed to accept booking"),
});

View File

@@ -1817,10 +1817,10 @@ export const api = {
({ id }) => bookingsService.remove(id),
),
staffAccept: endpoint<{ id: string }, BookingDetail>(
staffAccept: endpoint<{ id: string; validityDays: number }, BookingDetail>(
"bookings",
"staffAccept",
({ id }) => bookingsService.staffAccept(id),
({ id, validityDays }) => bookingsService.staffAccept(id, validityDays),
),
requestChanges: endpoint<{ id: string; note: string }, BookingDetail>(

View File

@@ -200,7 +200,8 @@ export const bookingsService = {
finalizeClearance: (id: string) =>
postBooking<BookingDetail>(`/bookings/${id}/clearance/finalize`),
staffAccept: (id: string) => postBooking<BookingDetail>(B.STAFF_ACCEPT(id)),
staffAccept: (id: string, validityDays: number) =>
postBooking<BookingDetail>(B.STAFF_ACCEPT(id), { validityDays }),
requestChanges: (id: string, note: string) =>
postBooking<BookingDetail>(B.STAFF_REQUEST_CHANGES(id), { note }),

View File

@@ -123,6 +123,10 @@ export interface BookingDetail {
adjustedByStaffId?: string | null;
adjustedAt?: string | null;
adjustmentReason?: string | null;
/** Contract validity window set by the backoffice when accepting. */
contractValidityDays?: number | null;
contractValidFrom?: string | null;
contractValidUntil?: string | null;
pricingBreakdown?: {
currency: string;
totalAmount: number;

View File

@@ -23,12 +23,14 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"leaflet": "^1.9.4",
"lucide-react": "^1.14.0",
"radix-ui": "^1.4.3",
"react": "19.2.6",
"react-dom": "19.2.6",
"react-hook-form": "^7.76.0",
"react-hot-toast": "^2.6.0",
"react-leaflet": "^5.0.0",
"react-phone-number-input": "^3.4.17",
"react-router-dom": "^6.27.0",
"recharts": "^3.8.1",
@@ -41,6 +43,7 @@
"@edr/tsconfig": "workspace:*",
"@hookform/devtools": "^4.4.0",
"@tailwindcss/vite": "^4.3.0",
"@types/leaflet": "^1.9.21",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.2",

View File

@@ -53,6 +53,7 @@ import {
type BookingFormValues,
} from "./new-booking-form/schema";
import { SelectField } from "./new-booking-form/shared";
import { LocationPicker } from "./new-booking-form/LocationPicker";
import { PaymentCurrencyField } from "./new-booking-form/payment-currency-field";
import { Step5CargoDetails, StepScheduling } from "./new-booking-form/steps";
@@ -127,11 +128,17 @@ function mapBookingToFormValues(
firstMile: {
enabled: booking.firstMileEnabled ?? false,
pickUpAddress: booking.firstMilePickupAddress ?? "",
lat: booking.firstMilePickupLat ?? null,
lng: booking.firstMilePickupLng ?? null,
},
lastMile: {
enabled: booking.lastMileEnabled ?? false,
deliveryAddress: booking.lastMileDeliveryAddress ?? "",
lat: booking.lastMileDeliveryLat ?? null,
lng: booking.lastMileDeliveryLng ?? null,
},
customsClearingEnabled: booking.customsClearingEnabled ?? false,
customsClearingAgent: booking.customsClearingAgent ?? "",
equipmentReturn:
booking.equipmentReturn === "WITH_RETURN"
? "with_return"
@@ -142,7 +149,6 @@ function mapBookingToFormValues(
cargoWeight: String(booking.cargoTotalWeightVgm ?? ""),
isHazardous: booking.isHazardous ?? false,
isRefrigerated: booking.isRefrigerated ?? false,
shippingLine: (booking as any).shippingLine?.id ?? "",
paymentCurrency:
booking.paymentCurrency === "ETB" ? "ETB" : "USD",
scheduledDate: booking.scheduledDate
@@ -338,12 +344,19 @@ export default function EditBookingPage() {
const serviceTypeId = form.watch("serviceTypeId");
const firstMileEnabled = form.watch("firstMile.enabled");
const lastMileEnabled = form.watch("lastMile.enabled");
const customsClearingEnabled = form.watch("customsClearingEnabled");
const documents = (form.watch("documents") ?? {}) as BookingDocuments;
const selectedService = useMemo(
() => referenceData?.service.find((s) => s.id === serviceTypeId),
[serviceTypeId, referenceData],
);
const showFirstMile = Boolean(
selectedService?.includesFirstMile && firstMileEnabled,
);
const showLastMile = Boolean(
selectedService?.includesLastMile && lastMileEnabled,
);
const direction = useMemo(() => {
const origin = referenceData?.yard.find((y) => y.id === originYard);
@@ -362,20 +375,6 @@ export default function EditBookingPage() {
}));
}, [referenceData]);
const shippingLineOptions = useMemo(() => {
if (!referenceData?.shipping_line) return [];
// Dedupe by name (the value the form keys on) so two lines sharing a name
// can't produce a duplicate Select option and crash Mantine.
const seen = new Set<string>();
const options: { value: string; label: string }[] = [];
for (const sl of referenceData.shipping_line) {
if (!sl.name || seen.has(sl.name)) continue;
seen.add(sl.name);
options.push({ value: sl.name, label: sl.name });
}
return options;
}, [referenceData]);
const setDocument = (key: string, file: File | null) => {
const current = (form.getValues("documents") ?? {}) as BookingDocuments;
form.setValue(
@@ -456,14 +455,25 @@ export default function EditBookingPage() {
? { pnrCode: data.previousContractRef }
: {}),
...(selectedSvc?.includesFirstMile && data.firstMile.enabled
? { firstMilePickupAddress: data.firstMile.pickUpAddress }
? {
firstMilePickupAddress: data.firstMile.pickUpAddress,
firstMilePickupLat: data.firstMile.lat ?? undefined,
firstMilePickupLng: data.firstMile.lng ?? undefined,
}
: {}),
...(selectedSvc?.includesLastMile && data.lastMile.enabled
? { lastMileDeliveryAddress: data.lastMile.deliveryAddress }
: {}),
...(data.shippingLine
? { shippingLineId: data.shippingLine }
? {
lastMileDeliveryAddress: data.lastMile.deliveryAddress,
lastMileDeliveryLat: data.lastMile.lat ?? undefined,
lastMileDeliveryLng: data.lastMile.lng ?? undefined,
}
: {}),
...(selectedSvc?.includesCustoms && data.customsClearingEnabled
? {
customsClearingEnabled: true,
customsClearingAgent: data.customsClearingAgent,
}
: { customsClearingEnabled: false }),
};
updateMutation.mutate(apiPayload);
@@ -634,27 +644,19 @@ export default function EditBookingPage() {
onChange={(value) => {
field.onChange(value);
if (!value) {
form.setValue("firstMile.pickUpAddress", "", {
shouldDirty: true,
shouldValidate: true,
});
form.setValue(
"firstMile",
{ enabled: false, pickUpAddress: "", lat: null, lng: null },
{ shouldDirty: true, shouldValidate: true },
);
}
}}
>
{firstMileEnabled && (
<Controller
name="firstMile.pickUpAddress"
control={form.control}
render={({ field: addr, fieldState }) => (
<TextInput
{...addr}
mt="sm"
radius="md"
placeholder="Pick-up address *"
error={fieldState.error?.message}
/>
)}
/>
<Text fz={12} c="#6B7C8E" mt="sm">
Set the exact pick-up location on the map in the Route
tab.
</Text>
)}
</ToggleRow>
)}
@@ -674,27 +676,19 @@ export default function EditBookingPage() {
onChange={(value) => {
field.onChange(value);
if (!value) {
form.setValue("lastMile.deliveryAddress", "", {
shouldDirty: true,
shouldValidate: true,
});
form.setValue(
"lastMile",
{ enabled: false, deliveryAddress: "", lat: null, lng: null },
{ shouldDirty: true, shouldValidate: true },
);
}
}}
>
{lastMileEnabled && (
<Controller
name="lastMile.deliveryAddress"
control={form.control}
render={({ field: addr, fieldState }) => (
<TextInput
{...addr}
mt="sm"
radius="md"
placeholder="Delivery address *"
error={fieldState.error?.message}
/>
)}
/>
<Text fz={12} c="#6B7C8E" mt="sm">
Set the exact delivery location on the map in the Route
tab.
</Text>
)}
</ToggleRow>
)}
@@ -711,8 +705,32 @@ export default function EditBookingPage() {
title="Customs Clearing Service"
description="EDR handles customs documentation and clearance on your behalf."
checked={field.value ?? false}
onChange={field.onChange}
/>
onChange={(value) => {
field.onChange(value);
if (!value) {
form.setValue("customsClearingAgent", "", {
shouldDirty: true,
shouldValidate: true,
});
}
}}
>
{customsClearingEnabled && (
<Controller
name="customsClearingAgent"
control={form.control}
render={({ field: agent, fieldState }) => (
<TextInput
{...agent}
mt="sm"
radius="md"
placeholder="Customs clearing agent *"
error={fieldState.error?.message}
/>
)}
/>
)}
</ToggleRow>
)}
/>
</Paper>
@@ -769,20 +787,73 @@ export default function EditBookingPage() {
</Alert>
)}
{direction && direction !== "DOMESTIC" && (
<Controller
name="shippingLine"
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Shipping Line"
placeholder="Select shipping line..."
data={shippingLineOptions}
{(showFirstMile || showLastMile) && (
<Stack gap="md">
<SectionHeading
title="Trucking locations"
description="Search an address or click the map to drop a pin for your door-to-port and port-to-door trucking."
/>
{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>
)}
<Paper withBorder radius="md">

View File

@@ -364,12 +364,20 @@ export default function NewBookingPage() {
// send 0.
const isPerItem =
bulkChild?.unit_of_measure === Freight.CargoUnitOfMeasure.PerItem;
const isContract = data.bookingType === "general_contract";
// For bulk general contracts the contracted quantity is entered against the
// primary route in the route step; one-time bookings use the cargo-step
// amount. Item counts are rounded since fractional items are meaningless.
const bulkAmountRaw =
isContract && data.cargoType === "bulk"
? data.primaryRouteQuantity
: data.cargoWeight;
const totalWeight =
data.cargoType === "container"
? 0
: isPerItem
? Math.round(Number(data.cargoWeight || 0))
: Number(data.cargoWeight || 0);
? Math.round(Number(bulkAmountRaw || 0))
: Number(bulkAmountRaw || 0);
const cargoTypeId = data.cargoType === "bulk" ? childId : undefined;
@@ -381,8 +389,6 @@ export default function NewBookingPage() {
(s) => s.id === data.serviceTypeId,
)!;
const isContract = data.bookingType === "general_contract";
return {
bookingType: isContract
? Freight.BookingType.GeneralContract
@@ -431,14 +437,27 @@ export default function NewBookingPage() {
? { pnrCode: data.previousContractRef }
: {}),
...(serviceType.includesFirstMile && data.firstMile.enabled
? { firstMilePickupAddress: data.firstMile.pickUpAddress }
? {
firstMilePickupAddress: data.firstMile.pickUpAddress,
firstMilePickupLat: data.firstMile.lat ?? undefined,
firstMilePickupLng: data.firstMile.lng ?? undefined,
}
: {}),
...(serviceType.includesLastMile && data.lastMile.enabled
? { lastMileDeliveryAddress: data.lastMile.deliveryAddress }
: {}),
...(data.shippingLine
? { shippingLineId: data.shippingLine }
? {
lastMileDeliveryAddress: data.lastMile.deliveryAddress,
lastMileDeliveryLat: data.lastMile.lat ?? undefined,
lastMileDeliveryLng: data.lastMile.lng ?? undefined,
}
: {}),
// Customs clearing: only when the service offers it and the customer opted
// in; the agent name is required by the form in that case.
...(serviceType.includesCustoms && data.customsClearingEnabled
? {
customsClearingEnabled: true,
customsClearingAgent: data.customsClearingAgent,
}
: { customsClearingEnabled: false }),
...(cargoFreeText ? { cargoFreeText } : {}),
// Multi-route general contracts: route #1 is the primary origin/destination
// carrying the full contracted quantity; each extra route reserves its own.

View File

@@ -0,0 +1,282 @@
import "leaflet/dist/leaflet.css";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Box, Combobox, InputBase, Loader, Text, useCombobox } from "@mantine/core";
import { MapPin, Search } from "lucide-react";
import L from "leaflet";
import { MapContainer, Marker, TileLayer, useMap, useMapEvents } from "react-leaflet";
import { fieldStyles } from "./shared";
/** A resolved place: a human address plus its coordinates. */
export interface LocationValue {
address: string;
lat: number | null;
lng: number | null;
}
/** A single Nominatim search result, normalised to what the UI needs. */
interface GeocodeResult {
displayName: string;
lat: number;
lng: number;
}
// Leaflet's default marker icon URLs break under bundlers; point them at the
// CDN-hosted assets once so every map instance renders a visible pin.
const markerIcon = L.icon({
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41],
});
// Centre of the EDR corridor (Addis Ababa) — a sensible default view.
const DEFAULT_CENTER: [number, number] = [9.03, 38.74];
const DEFAULT_ZOOM = 6;
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;
/** Forward-geocode a free-text query to candidate places (free Nominatim API). */
async function searchPlaces(query: string, signal: AbortSignal): Promise<GeocodeResult[]> {
const params = new URLSearchParams({
q: query,
format: "json",
addressdetails: "0",
limit: "6",
});
const res = await fetch(`${NOMINATIM_URL}?${params}`, {
signal,
headers: { Accept: "application/json" },
});
if (!res.ok) return [];
const data = (await res.json()) as Array<{
display_name: string;
lat: string;
lon: string;
}>;
return data.map((d) => ({
displayName: d.display_name,
lat: Number(d.lat),
lng: Number(d.lon),
}));
}
/** Reverse-geocode a dropped pin to its nearest address. */
async function reverseGeocode(lat: number, lng: number): Promise<string> {
const params = new URLSearchParams({
lat: String(lat),
lon: String(lng),
format: "json",
});
try {
const res = await fetch(`${NOMINATIM_REVERSE_URL}?${params}`, {
headers: { Accept: "application/json" },
});
if (!res.ok) return "";
const data = (await res.json()) as { display_name?: string };
return data.display_name ?? "";
} catch {
return "";
}
}
/** Recenters the map imperatively when the pinned coordinate changes. */
function MapRecenter({ lat, lng }: { lat: number | null; lng: number | null }) {
const map = useMap();
useEffect(() => {
if (lat != null && lng != null) {
map.setView([lat, lng], PINNED_ZOOM, { animate: true });
}
}, [lat, lng, map]);
return null;
}
/** Captures map clicks and forwards the dropped coordinate. */
function ClickToPin({ onPick }: { onPick: (lat: number, lng: number) => void }) {
useMapEvents({
click: (e) => onPick(e.latlng.lat, e.latlng.lng),
});
return null;
}
export interface LocationPickerProps {
value: LocationValue;
onChange: (value: LocationValue) => void;
label: string;
placeholder?: string;
error?: string;
}
/**
* Address + map location picker backed by free OpenStreetMap services:
* - type to search (Nominatim forward geocoding),
* - or click anywhere on the map to drop a pin (Nominatim reverse geocoding).
* Reports the resolved address and coordinates up via `onChange`.
*/
export function LocationPicker({
value,
onChange,
label,
placeholder = "Search an address or click the map…",
error,
}: LocationPickerProps) {
const combobox = useCombobox();
const [query, setQuery] = useState("");
const [results, setResults] = useState<GeocodeResult[]>([]);
const [searching, setSearching] = useState(false);
const [resolving, setResolving] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const hasPin = value.lat != null && value.lng != null;
// Debounced forward search as the user types.
useEffect(() => {
const q = query.trim();
if (q.length < 3) {
setResults([]);
setSearching(false);
return;
}
setSearching(true);
const handle = setTimeout(async () => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
try {
const found = await searchPlaces(q, controller.signal);
setResults(found);
} catch {
setResults([]);
} finally {
setSearching(false);
}
}, SEARCH_DEBOUNCE_MS);
return () => clearTimeout(handle);
}, [query]);
const selectResult = useCallback(
(r: GeocodeResult) => {
onChange({ address: r.displayName, lat: r.lat, lng: r.lng });
setQuery("");
setResults([]);
combobox.closeDropdown();
},
[onChange, combobox],
);
const handlePin = useCallback(
async (lat: number, lng: number) => {
// Show the pin immediately; fill the address once reverse geocoding lands.
onChange({ address: value.address, lat, lng });
setResolving(true);
const address = await reverseGeocode(lat, lng);
setResolving(false);
onChange({
address: address || `${lat.toFixed(5)}, ${lng.toFixed(5)}`,
lat,
lng,
});
},
[onChange, value.address],
);
const inputValue = query || value.address;
const center = useMemo<[number, number]>(
() => (hasPin ? [value.lat as number, value.lng as number] : DEFAULT_CENTER),
[hasPin, value.lat, value.lng],
);
return (
<Box>
<Combobox store={combobox} withinPortal shadow="md" radius="md">
<Combobox.Target>
<InputBase
label={label}
placeholder={placeholder}
value={inputValue}
error={error}
radius={10}
styles={fieldStyles}
leftSection={<Search size={16} />}
rightSection={searching || resolving ? <Loader size={14} /> : null}
onChange={(e) => {
setQuery(e.currentTarget.value);
combobox.openDropdown();
}}
onFocus={() => results.length > 0 && combobox.openDropdown()}
/>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options>
{searching ? (
<Combobox.Empty>Searching</Combobox.Empty>
) : results.length === 0 ? (
<Combobox.Empty>
{query.trim().length < 3
? "Type at least 3 characters"
: "No matching places"}
</Combobox.Empty>
) : (
results.map((r, i) => (
<Combobox.Option
key={`${r.lat}-${r.lng}-${i}`}
value={String(i)}
onClick={() => selectResult(r)}
>
<Text fz={13} lineClamp={2}>
{r.displayName}
</Text>
</Combobox.Option>
))
)}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
<Box
mt={10}
style={{
height: 260,
borderRadius: 12,
overflow: "hidden",
border: "1px solid #E6ECF2",
}}
>
<MapContainer
center={center}
zoom={hasPin ? PINNED_ZOOM : DEFAULT_ZOOM}
style={{ height: "100%", width: "100%" }}
scrollWheelZoom
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<ClickToPin onPick={handlePin} />
<MapRecenter lat={value.lat} lng={value.lng} />
{hasPin && (
<Marker
position={[value.lat as number, value.lng as number]}
icon={markerIcon}
/>
)}
</MapContainer>
</Box>
<Text fz={11.5} c="#6B7C8E" mt={6} style={{ display: "flex", gap: 5 }}>
<MapPin size={13} style={{ flexShrink: 0, marginTop: 1 }} />
{hasPin
? value.address || "Pinned location"
: "Search above or click the map to drop a pin."}
</Text>
</Box>
);
}

View File

@@ -114,26 +114,39 @@ export const bookingFormSchema = z
.object({
enabled: z.boolean().default(false),
pickUpAddress: z.string(),
// Coordinates resolved by the map picker (geocode/search or pin drop).
lat: z.number().nullable().default(null),
lng: z.number().nullable().default(null),
})
.refine((data) => !(data.enabled && !data.pickUpAddress.trim()), {
message: "Enter the pick-up address.",
message: "Select the pick-up location on the map.",
path: ["pickUpAddress"],
}),
lastMile: z
.object({
enabled: z.boolean().default(false),
deliveryAddress: z.string(),
lat: z.number().nullable().default(null),
lng: z.number().nullable().default(null),
})
.refine((data) => !(data.enabled && !data.deliveryAddress.trim()), {
message: "Enter the delivery address.",
message: "Select the delivery location on the map.",
path: ["deliveryAddress"],
}),
equipmentReturn: z
.enum(["with_return", "without_return"])
.default("with_return"),
customsClearingEnabled: z.boolean().default(false),
// Required only when customs clearing is enabled (validated in superRefine).
customsClearingAgent: z.string().default(""),
originYard: z.string().min(1, "Select an origin yard."),
destinationYard: z.string().min(1, "Select a destination yard."),
// Quantity reserved on the PRIMARY route of a GENERAL contract, in the unit
// of the selected commodity (items vs tons). Customers enter it explicitly in
// the route step so the primary route reads consistently with the extra
// routes below. Ignored for one-time bookings; for containers the value is
// derived from the container count instead (see buildApiPayload).
primaryRouteQuantity: z.string().default(""),
// Additional routes for a GENERAL contract (the primary origin/destination
// above is route #1). Each adds another (origin, destination, quantity) pool.
// Ignored for one-time bookings.
@@ -146,7 +159,6 @@ export const bookingFormSchema = z
}),
)
.default([]),
shippingLine: z.string(),
// Day-level pool: the customer selects only a DAY. The batch engine assigns
// the specific train later, so no trainScheduleId is collected here.
// Optional in the base schema — required for one-time bookings via the
@@ -208,7 +220,11 @@ export const bookingFormSchema = z
)
.refine(
(data) => {
// General contracts capture bulk quantity per route (primaryRouteQuantity),
// not via the cargo-step cargoWeight — so only validate it for one-time
// bulk bookings.
if (data.cargoType !== "bulk") return true;
if (data.bookingType === "general_contract") return true;
const quantity = Number(data.cargoWeight);
return !!data.cargoWeight && !Number.isNaN(quantity) && quantity > 0;
},
@@ -227,6 +243,14 @@ export const bookingFormSchema = z
message: "Select a shipment date.",
});
}
// Customs clearing agent is required once the customs service is enabled.
if (data.customsClearingEnabled && !data.customsClearingAgent.trim()) {
ctx.addIssue({
code: "custom",
path: ["customsClearingAgent"],
message: "Enter the customs clearing agent.",
});
}
if (data.cargoType === "bulk") {
if (!data.cargoTypePath[0]) {
ctx.addIssue({
@@ -236,6 +260,19 @@ export const bookingFormSchema = z
});
}
}
// General contracts reserve quantity per route. The primary route's quantity
// is entered in the route step; containers derive it from the container
// count, so only bulk cargo requires it here.
if (data.bookingType === "general_contract" && data.cargoType === "bulk") {
const qty = Number(data.primaryRouteQuantity);
if (!data.primaryRouteQuantity || Number.isNaN(qty) || qty <= 0) {
ctx.addIssue({
code: "custom",
path: ["primaryRouteQuantity"],
message: "Enter a quantity greater than 0.",
});
}
}
if (data.cargoType === "container") {
data.containers.forEach((c, i) => {
if (!c.qty || +c.qty < 1) {
@@ -261,17 +298,22 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
firstMile: {
enabled: false,
pickUpAddress: "",
lat: null,
lng: null,
},
lastMile: {
enabled: false,
deliveryAddress: "",
lat: null,
lng: null,
},
equipmentReturn: "with_return",
customsClearingEnabled: false,
customsClearingAgent: "",
originYard: "",
destinationYard: "",
primaryRouteQuantity: "",
extraRoutes: [],
shippingLine: "",
scheduledDate: "",
cargoWeight: "",
cargoTypePath: [],
@@ -289,19 +331,21 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
2: [
"serviceTypeId",
"paymentCurrency",
"firstMile",
"lastMile",
"equipmentReturn",
"customsClearingEnabled",
"customsClearingAgent",
],
3: ["cargoType", "cargoWeight", "cargoTypePath", "containers"],
4: [
"originYard",
"destinationYard",
"primaryRouteQuantity",
"extraRoutes",
// First/last-mile pickup & delivery locations are captured here on the map.
"firstMile",
"lastMile",
"isHazardous",
"isRefrigerated",
"shippingLine",
],
5: ["scheduledDate"],
6: ["documents"],
@@ -312,7 +356,9 @@ export interface ContainerConfig {
type: "20ft" | "40ft";
containerType: string;
qty: string;
vgm: string;
// Optional: VGM is captured later in operations, not at the wizard, and the
// form schema defaults it — so the watched input shape has it as optional.
vgm?: string;
}
export interface WagonConfig {

View File

@@ -88,18 +88,19 @@ export function Step1ContractType({
const serviceId = service?.id || booking.serviceTypeId;
if (serviceId) form.setValue("serviceTypeId", serviceId);
// ── First / last mile ───────────────────────────────────────────────
form.setValue("firstMile.enabled", booking.firstMileEnabled);
if (booking.firstMilePickupAddress) {
form.setValue("firstMile.pickUpAddress", booking.firstMilePickupAddress);
}
form.setValue("lastMile.enabled", booking.lastMileEnabled);
if (booking.lastMileDeliveryAddress) {
form.setValue(
"lastMile.deliveryAddress",
booking.lastMileDeliveryAddress,
);
}
// ── First / last mile (address + map coordinates) ───────────────────
form.setValue("firstMile", {
enabled: booking.firstMileEnabled,
pickUpAddress: booking.firstMilePickupAddress ?? "",
lat: booking.firstMilePickupLat ?? null,
lng: booking.firstMilePickupLng ?? null,
});
form.setValue("lastMile", {
enabled: booking.lastMileEnabled,
deliveryAddress: booking.lastMileDeliveryAddress ?? "",
lat: booking.lastMileDeliveryLat ?? null,
lng: booking.lastMileDeliveryLng ?? null,
});
// ── Equipment return ────────────────────────────────────────────────
form.setValue(
@@ -110,9 +111,11 @@ export function Step1ContractType({
);
// ── Customs ─────────────────────────────────────────────────────────
if (service) {
form.setValue("customsClearingEnabled", service.includesCustoms);
}
form.setValue(
"customsClearingEnabled",
booking.customsClearingEnabled ?? service?.includesCustoms ?? false,
);
form.setValue("customsClearingAgent", booking.customsClearingAgent ?? "");
// ── Route ───────────────────────────────────────────────────────────
if (booking.originYard?.id) {
@@ -122,16 +125,6 @@ export function Step1ContractType({
form.setValue("destinationYard", booking.destinationYard.id);
}
// ── Shipping line ───────────────────────────────────────────────────
if (booking.shippingLineId && referenceData?.shipping_line) {
const shippingLine = referenceData.shipping_line.find(
(sl) => sl.id === booking.shippingLineId,
);
if (shippingLine) {
form.setValue("shippingLine", shippingLine.id);
}
}
// ── Cargo type ──────────────────────────────────────────────────────
form.setValue(
"cargoType",
@@ -139,8 +132,15 @@ export function Step1ContractType({
);
// ── Cargo weight (bulk) ─────────────────────────────────────────────
// Carry the prior amount into both the cargo-step weight (one-time path)
// and the primary route quantity (general-contract path) so whichever input
// is shown is prefilled.
if (booking.cargoTotalWeightVgm > 0) {
form.setValue("cargoWeight", String(booking.cargoTotalWeightVgm));
form.setValue(
"primaryRouteQuantity",
String(booking.cargoTotalWeightVgm),
);
}
// ── Hazardous / refrigerated ────────────────────────────────────────

View File

@@ -39,6 +39,7 @@ export function Step2ServiceType({
serviceType ?? {};
const firstMileEnabled = form.watch("firstMile.enabled");
const lastMileEnabled = form.watch("lastMile.enabled");
const customsClearingEnabled = form.watch("customsClearingEnabled");
const prevServiceType = useRef(serviceType);
useEffect(() => {
@@ -122,28 +123,20 @@ export function Step2ServiceType({
onChange={(value) => {
field.onChange(value);
if (!value) {
form.setValue("firstMile.pickUpAddress", "", {
shouldDirty: true,
shouldValidate: true,
});
// Clear the captured pick-up location (set on the Route step).
form.setValue(
"firstMile",
{ enabled: false, pickUpAddress: "", lat: null, lng: null },
{ shouldDirty: true, shouldValidate: true },
);
}
}}
>
{firstMileEnabled && (
<Controller
name="firstMile.pickUpAddress"
control={form.control}
render={({ field: af, fieldState }) => (
<TextInput
{...af}
mt="sm"
placeholder="Pick-up address *"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
<Text fz={12} c="#6B7C8E" mt="sm">
Youll pick the exact pick-up location on the map in the
Route step.
</Text>
)}
</ServiceToggle>
)}
@@ -164,10 +157,12 @@ export function Step2ServiceType({
onChange={(value) => {
field.onChange(value);
if (!value) {
form.setValue("lastMile.deliveryAddress", "", {
shouldDirty: true,
shouldValidate: true,
});
// Clear the captured delivery location (set on the Route step).
form.setValue(
"lastMile",
{ enabled: false, deliveryAddress: "", lat: null, lng: null },
{ shouldDirty: true, shouldValidate: true },
);
form.setValue("equipmentReturn", "with_return", {
shouldDirty: true,
});
@@ -175,20 +170,10 @@ export function Step2ServiceType({
}}
>
{lastMileEnabled && (
<Controller
name="lastMile.deliveryAddress"
control={form.control}
render={({ field: af, fieldState }) => (
<TextInput
{...af}
mt="sm"
placeholder="Delivery address *"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
<Text fz={12} c="#6B7C8E" mt="sm">
Youll pick the exact delivery location on the map in the
Route step.
</Text>
)}
</ServiceToggle>
)}
@@ -229,8 +214,33 @@ export function Step2ServiceType({
title="Customs Clearing Service"
description="EDR handles customs documentation and clearance on your behalf."
checked={field.value ?? false}
onChange={(v) => field.onChange(v)}
/>
onChange={(value) => {
field.onChange(value);
if (!value) {
form.setValue("customsClearingAgent", "", {
shouldDirty: true,
shouldValidate: true,
});
}
}}
>
{customsClearingEnabled && (
<Controller
name="customsClearingAgent"
control={form.control}
render={({ field: af, fieldState }) => (
<TextInput
{...af}
mt="sm"
placeholder="Customs clearing agent *"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
)}
</ServiceToggle>
)}
/>
)}

View File

@@ -18,7 +18,7 @@ import {
Snowflake,
Trash2,
} from "lucide-react";
import { useEffect, useMemo } from "react";
import { useMemo } from "react";
import {
Controller,
useFieldArray,
@@ -30,6 +30,7 @@ import {
getRouteDirection,
} from "./schema";
import { SelectField, StepCard, StepHeader, StepLabel } from "./shared";
import { LocationPicker } from "./LocationPicker";
type BookingForm = UseFormReturn<
BookingFormInputValues,
@@ -49,6 +50,13 @@ 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,
@@ -61,21 +69,6 @@ export function Step4Route({
return referenceData.yard.map((y) => ({ value: y.id, label: y.name }));
}, [referenceData]);
const shippingLineOptions = useMemo(() => {
if (!referenceData?.shipping_line) return [];
// The form keys shipping line by ID (required by API as UUID).
// Dedupe by name: if the reference data has two lines sharing a name, a
// duplicate option would crash Mantine's Select ("Duplicate options...").
const seen = new Set<string>();
const options: { value: string; label: string }[] = [];
for (const sl of referenceData.shipping_line) {
if (!sl.name || seen.has(sl.name)) continue;
seen.add(sl.name);
options.push({ value: sl.id, label: sl.name });
}
return options;
}, [referenceData]);
const originData = useMemo(() => {
return yardOptions
.filter((o) => o.value !== destinationYard)
@@ -110,14 +103,28 @@ export function Step4Route({
DOMESTIC: "Domestic corridor",
};
useEffect(() => {
if (direction === "DOMESTIC") {
form.setValue("shippingLine", "", { shouldDirty: true });
}
}, [direction]);
const stationSelectDisabled = yardOptions.length === 0;
// General contracts reserve quantity per route. The unit (items vs tons) comes
// from the commodity picked in the cargo step, mirroring step5-cargo-details:
// PER_ITEM → a whole item count; otherwise an estimated tonnage. Container
// contracts reserve quantity by container count instead, so no quantity input
// is shown for them here.
const cargoType = form.watch("cargoType");
const cargoTypePath = form.watch("cargoTypePath") ?? [];
const isContainer = cargoType === "container";
const selectedCommodity = useMemo(() => {
const parentId = cargoTypePath[0];
const childId = cargoTypePath[1];
if (!referenceData?.cargo_type || !parentId || !childId) return null;
const group = referenceData.cargo_type.find((g) => g.id === parentId);
return group?.children?.find((c) => c.id === childId) ?? null;
}, [referenceData, cargoTypePath]);
const isPerItem = selectedCommodity?.unit_of_measure === "PER_ITEM";
const quantityLabel = isPerItem ? "Quantity (Items)" : "Quantity (Tons)";
const quantityStep = isPerItem ? 1 : 0.01;
const showRouteQuantity = isGeneralContract && !isContainer;
return (
<StepCard>
<StepHeader
@@ -169,6 +176,27 @@ export function Step4Route({
{directionLabel[direction]}
</div>
)}
{showRouteQuantity && (
<Box style={{ maxWidth: 220 }}>
<Controller
name="primaryRouteQuantity"
control={form.control}
render={({ field, fieldState }) => (
<NumberInput
label={`${quantityLabel} *`}
placeholder={isPerItem ? "e.g. 500" : "e.g. 1200"}
description="Quantity reserved on the primary route."
min={0}
step={quantityStep}
error={fieldState.error?.message}
value={field.value === "" ? "" : Number(field.value)}
onChange={(v) => field.onChange(String(v ?? ""))}
radius="md"
/>
)}
/>
</Box>
)}
</div>
)}
@@ -196,8 +224,8 @@ export function Step4Route({
</Group>
<Text fz={12} c="#6B7C8E" mb={12}>
A general contract can reserve quantity across several routes. The
route above is your primary route; add more routes and the quantity
reserved for each.
route above is your primary route; add more routes and set the
quantity reserved for each.
</Text>
<Stack gap={12}>
{extraRoutes.map((rf, i) => (
@@ -239,15 +267,16 @@ export function Step4Route({
)}
/>
</Box>
<Box style={{ width: 120 }}>
<Box style={{ width: 140 }}>
<Controller
name={`extraRoutes.${i}.quantity`}
control={form.control}
render={({ field }) => (
<NumberInput
label="Quantity"
label={quantityLabel}
placeholder="0"
min={0}
step={quantityStep}
value={field.value === "" ? "" : Number(field.value)}
onChange={(v) => field.onChange(String(v ?? ""))}
radius="md"
@@ -271,20 +300,76 @@ export function Step4Route({
</Box>
)}
{direction && direction !== "DOMESTIC" && (
<Controller
name="shippingLine"
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Shipping Line"
placeholder="Select shipping line..."
data={shippingLineOptions}
/>
)}
/>
{(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" />

View File

@@ -39,6 +39,10 @@ export function Step5CargoDetails({
const parentId = cargoTypePath[0];
const childId = cargoTypePath[1];
const containers = form.watch("containers");
// General contracts reserve quantity per route, captured in the route step
// against each route. So the single cargo-level quantity below is only asked
// for one-time bookings; contracts skip it here to avoid a duplicate input.
const isGeneralContract = form.watch("bookingType") === "general_contract";
const { fields, append, remove } = useFieldArray({
control: form.control,
@@ -223,8 +227,9 @@ export function Step5CargoDetails({
{/* Quantity — only once a commodity is chosen, so the unit (tons vs
items) is known. PER_TON asks for estimated tons; PER_ITEM asks
for the total item count. */}
{selectedCommodity && (
for the total item count. General contracts collect this per route
in the route step instead, so it's hidden here for them. */}
{selectedCommodity && !isGeneralContract && (
<Controller
name="cargoWeight"
control={form.control}

View File

@@ -150,9 +150,6 @@ export function Step8Review({
const serviceType = referenceData?.service.find(
(s) => s.id === values.serviceTypeId,
);
const shippingLine = referenceData?.shipping_line.find(
(sl) => sl.id === values.shippingLine,
);
const containerSummary =
values.cargoType === "container" && values.containers.length > 0
@@ -162,13 +159,21 @@ export function Step8Review({
.join(", ")
: "";
// For bulk general contracts the quantity is reserved per route (the primary
// route's amount lives in primaryRouteQuantity); one-time bookings use the
// cargo-step cargoWeight.
const isGeneralContract = values.bookingType === "general_contract";
const bulkAmount =
isGeneralContract && values.cargoType === "bulk"
? Number(values.primaryRouteQuantity || 0)
: Number(values.cargoWeight || 0);
const totalVgm =
values.cargoType === "container"
? values.containers.reduce(
(sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0),
(sum, c) => sum + (+c.qty || 0) * (+(c.vgm ?? 0) || 0),
0,
)
: Number(values.cargoWeight || 0);
: bulkAmount;
// Documents are reused from onboarding (read-only) and attached on submit.
const onboardingDocsCount = onboardingDocs.length;
@@ -291,7 +296,6 @@ export function Step8Review({
value={`${originYardName}${destinationYardName}`}
/>
<DetailRow label="Trade direction" value={directionLabel} />
<DetailRow label="Shipping line" value={shippingLine?.name || "—"} />
<DetailRow
label="Modifiers"
value={
@@ -333,7 +337,13 @@ export function Step8Review({
/>
<DetailRow
label="Customs clearing"
value={values.customsClearingEnabled ? "Enabled" : "Not requested"}
value={
values.customsClearingEnabled
? values.customsClearingAgent
? `Enabled — agent: ${values.customsClearingAgent}`
: "Enabled"
: "Not requested"
}
/>
</OverviewSection>
@@ -461,7 +471,7 @@ export function Step8Review({
done={
values.cargoType === "container"
? values.containers.some((c) => +c.qty > 0)
: Boolean(values.cargoWeight)
: bulkAmount > 0
}
label="Cargo details complete"
/>

View File

@@ -128,7 +128,7 @@ export const api = {
companiesService.updateProfile,
),
getDashboard: endpoint<string | void, DashboardSummary>(
getDashboard: endpoint<string | undefined, DashboardSummary>(
"companies",
"getDashboard",
companiesService.getDashboard,

View File

@@ -365,6 +365,13 @@ export interface IBooking extends BaseEntity {
adjustedByStaffId?: string | null;
adjustedAt?: string | null;
adjustmentReason?: string | null;
/**
* Contract validity window set by the backoffice at the accept step. Valid
* from contractValidFrom through contractValidUntil (validFrom + N days).
*/
contractValidityDays?: number | null;
contractValidFrom?: string | null;
contractValidUntil?: string | null;
paymentStatus: PaymentStatus;
shippingLineId?: string | null;
@@ -376,8 +383,15 @@ export interface IBooking extends BaseEntity {
firstMileEnabled: boolean;
firstMilePickupAddress?: string | null;
firstMilePickupLat?: number | null;
firstMilePickupLng?: number | null;
lastMileEnabled: boolean;
lastMileDeliveryAddress?: string | null;
lastMileDeliveryLat?: number | null;
lastMileDeliveryLng?: number | null;
customsClearingEnabled?: boolean;
customsClearingAgent?: string | null;
equipmentReturn: "WITH_RETURN" | "WITHOUT_RETURN";
originYard?: IYard | null;
@@ -640,7 +654,13 @@ export interface CreateBookingDto {
previousContractId?: string | undefined;
serviceTypeId: string;
firstMilePickupAddress?: string | undefined;
firstMilePickupLat?: number | undefined;
firstMilePickupLng?: number | undefined;
lastMileDeliveryAddress?: string | undefined;
lastMileDeliveryLat?: number | undefined;
lastMileDeliveryLng?: number | undefined;
customsClearingEnabled?: boolean | undefined;
customsClearingAgent?: string | undefined;
equipmentReturn: string;
originYardId: string;
destinationYardId: string;

86
pnpm-lock.yaml generated
View File

@@ -341,6 +341,9 @@ importers:
date-fns:
specifier: ^3.6.0
version: 3.6.0
leaflet:
specifier: ^1.9.4
version: 1.9.4
lucide-react:
specifier: ^1.14.0
version: 1.17.0(react@19.2.6)
@@ -359,6 +362,9 @@ importers:
react-hot-toast:
specifier: ^2.6.0
version: 2.6.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-leaflet:
specifier: ^5.0.0
version: 5.0.0(leaflet@1.9.4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-phone-number-input:
specifier: ^3.4.17
version: 3.4.17(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -390,6 +396,9 @@ importers:
'@tailwindcss/vite':
specifier: ^4.3.0
version: 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0))
'@types/leaflet':
specifier: ^1.9.21
version: 1.9.21
'@types/react':
specifier: ^18.3.11
version: 18.3.31
@@ -449,7 +458,7 @@ importers:
version: 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
'@nestjs/microservices':
specifier: ^11.1.24
version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@0.10.9))(amqplib@0.10.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/passport':
specifier: ^10.0.3
version: 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
@@ -3462,6 +3471,13 @@ packages:
'@radix-ui/rect@1.1.2':
resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==}
'@react-leaflet/core@3.0.0':
resolution: {integrity: sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==}
peerDependencies:
leaflet: ^1.9.0
react: ^19.0.0
react-dom: ^19.0.0
'@react-pdf-viewer/attachment@3.12.0':
resolution: {integrity: sha512-mhwrYJSIpCvHdERpLUotqhMgSjhtF+BTY1Yb9Fnzpcq3gLZP+Twp5Rynq21tCrVdDizPaVY7SKu400GkgdMfZw==}
peerDependencies:
@@ -4165,6 +4181,9 @@ packages:
'@types/express@5.0.6':
resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==}
'@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
'@types/graceful-fs@4.1.9':
resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==}
@@ -4203,6 +4222,9 @@ packages:
'@types/jsonwebtoken@9.0.5':
resolution: {integrity: sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==}
'@types/leaflet@1.9.21':
resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==}
'@types/lodash@4.17.24':
resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==}
@@ -7151,11 +7173,6 @@ packages:
react-dom:
optional: true
internal-ip@1.2.0:
resolution: {integrity: sha512-DzGfTasXPmwizQP4XV2rR6r2vp8TjlOpMnJqG9Iy2i1pl1lkZdZj5rSpIc7YFGX2nS46PPgAGEyT+Q5hE2FB2g==}
engines: {node: '>=0.10.0'}
hasBin: true
internal-slot@1.1.0:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
@@ -7836,6 +7853,9 @@ packages:
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
engines: {node: '>= 0.6.3'}
leaflet@1.9.4:
resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
leven@3.1.0:
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
engines: {node: '>=6'}
@@ -9243,6 +9263,13 @@ packages:
react-is@19.2.7:
resolution: {integrity: sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==}
react-leaflet@5.0.0:
resolution: {integrity: sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==}
peerDependencies:
leaflet: ^1.9.0
react: ^19.0.0
react-dom: ^19.0.0
react-number-format@5.4.5:
resolution: {integrity: sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw==}
peerDependencies:
@@ -12841,7 +12868,7 @@ snapshots:
tslib: 2.8.1
uid: 2.0.2
optionalDependencies:
'@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@0.10.9))(amqplib@0.10.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/platform-express': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
'@nestjs/event-emitter@2.1.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)':
@@ -12872,6 +12899,18 @@ snapshots:
class-transformer: 0.5.1
class-validator: 0.14.4
'@nestjs/microservices@11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@0.10.9))(amqplib@0.10.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
dependencies:
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
iterare: 1.2.1
reflect-metadata: 0.2.2
rxjs: 7.8.2
tslib: 2.8.1
optionalDependencies:
amqp-connection-manager: 5.0.0(amqplib@0.10.9)
amqplib: 0.10.9
'@nestjs/microservices@11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
dependencies:
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -12956,7 +12995,7 @@ snapshots:
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
tslib: 2.8.1
optionalDependencies:
'@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@0.10.9))(amqplib@0.10.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/platform-express': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
'@nestjs/throttler@6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)':
@@ -14656,6 +14695,12 @@ snapshots:
'@radix-ui/rect@1.1.2': {}
'@react-leaflet/core@3.0.0(leaflet@1.9.4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
leaflet: 1.9.4
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
'@react-pdf-viewer/attachment@3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@react-pdf-viewer/core': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -15543,6 +15588,8 @@ snapshots:
'@types/express-serve-static-core': 5.1.1
'@types/serve-static': 2.2.0
'@types/geojson@7946.0.16': {}
'@types/graceful-fs@4.1.9':
dependencies:
'@types/node': 20.19.42
@@ -15584,6 +15631,10 @@ snapshots:
dependencies:
'@types/node': 20.19.42
'@types/leaflet@1.9.21':
dependencies:
'@types/geojson': 7946.0.16
'@types/lodash@4.17.24': {}
'@types/luxon@3.7.1': {}
@@ -16106,6 +16157,12 @@ snapshots:
amqplib: 0.10.9
promise-breaker: 6.0.0
amqp-connection-manager@5.0.0(amqplib@0.10.9):
dependencies:
amqplib: 0.10.9
promise-breaker: 6.0.0
optional: true
amqp-connection-manager@5.0.0(amqplib@2.0.1):
dependencies:
amqplib: 2.0.1
@@ -18963,10 +19020,6 @@ snapshots:
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
internal-ip@1.2.0:
dependencies:
meow: 3.7.0
internal-slot@1.1.0:
dependencies:
es-errors: 1.3.0
@@ -19827,6 +19880,8 @@ snapshots:
dependencies:
readable-stream: 2.3.8
leaflet@1.9.4: {}
leven@3.1.0: {}
levn@0.4.1:
@@ -21326,6 +21381,13 @@ snapshots:
react-is@19.2.7: {}
react-leaflet@5.0.0(leaflet@1.9.4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
'@react-leaflet/core': 3.0.0(leaflet@1.9.4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
leaflet: 1.9.4
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
react-number-format@5.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1