diff --git a/apps/edr-freight-api/src/main.ts b/apps/edr-freight-api/src/main.ts index a76378b0c..0107956e4 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -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()); diff --git a/apps/edr-freight-api/src/migrations/1820000000005-AddContractValidityWindow.ts b/apps/edr-freight-api/src/migrations/1820000000005-AddContractValidityWindow.ts new file mode 100644 index 000000000..56d923ff7 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1820000000005-AddContractValidityWindow.ts @@ -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 { + 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 { + 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;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1820000000006-AddCustomsAgentAndMileCoordinates.ts b/apps/edr-freight-api/src/migrations/1820000000006-AddCustomsAgentAndMileCoordinates.ts new file mode 100644 index 000000000..11d1b617a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1820000000006-AddCustomsAgentAndMileCoordinates.ts @@ -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 { + 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 { + 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; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts new file mode 100644 index 000000000..6568e3113 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts @@ -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' }), + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 82b13c4bc..27c5efa87 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -198,13 +198,31 @@ export class BookingTransitionService { }); } - async acceptIntake(bookingId: string, actorId: string): Promise { + async acceptIntake( + bookingId: string, + actorId: string, + validityDays: number, + ): Promise { 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); } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index d650098f9..4b8b44bf9 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -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); } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 21b145774..a2a6db1d1 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.spec.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.spec.ts new file mode 100644 index 000000000..e6a4b717b --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.spec.ts @@ -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) => + 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'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index 7a8468dcb..500c77d79 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts index d598b948e..6fca0884b 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 9586e4bb8..23fed57b1 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -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; diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx index bfc489e77..dac36f7b9 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx @@ -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 && ( + + onInputChange(value === "" ? "" : String(value))} + /> + + {daysValid + ? `Contract valid from today until ${validUntilLabel(daysValue)} (${daysValue} day${daysValue === 1 ? "" : "s"}).` + : "Enter a whole number of days between 1 and 365."} + + + )} {extra} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts b/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts index 0b7238caf..56cd95241 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts @@ -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, 1–365. */ +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, diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts b/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts index cdb0d3d83..04b8a70b9 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts @@ -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", diff --git a/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts b/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts index d68535f78..f89b0a411 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts @@ -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"), }); diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index fdaa1a6cb..513d7949d 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -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>( diff --git a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts index 697caed54..8e693d9df 100644 --- a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts @@ -200,7 +200,8 @@ export const bookingsService = { finalizeClearance: (id: string) => postBooking(`/bookings/${id}/clearance/finalize`), - staffAccept: (id: string) => postBooking(B.STAFF_ACCEPT(id)), + staffAccept: (id: string, validityDays: number) => + postBooking(B.STAFF_ACCEPT(id), { validityDays }), requestChanges: (id: string, note: string) => postBooking(B.STAFF_REQUEST_CHANGES(id), { note }), diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index 35dc26c7b..e8e6a6912 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -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; diff --git a/apps/edr-freight-web/portal/package.json b/apps/edr-freight-web/portal/package.json index 2ac8e84ca..248086188 100644 --- a/apps/edr-freight-web/portal/package.json +++ b/apps/edr-freight-web/portal/package.json @@ -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", diff --git a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx index 07a778d6d..94a8dd423 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx @@ -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(); - 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 && ( - ( - - )} - /> + + Set the exact pick-up location on the map in the Route + tab. + )} )} @@ -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 && ( - ( - - )} - /> + + Set the exact delivery location on the map in the Route + tab. + )} )} @@ -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 && ( + ( + + )} + /> + )} + )} /> @@ -769,20 +787,73 @@ export default function EditBookingPage() { )} - {direction && direction !== "DOMESTIC" && ( - ( - + + {showFirstMile && ( + ( + + field.onChange({ + ...field.value, + enabled: true, + pickUpAddress: loc.address, + lat: loc.lat, + lng: loc.lng, + }) + } + /> + )} /> )} - /> + {showLastMile && ( + ( + + field.onChange({ + ...field.value, + enabled: true, + deliveryAddress: loc.address, + lat: loc.lat, + lng: loc.lng, + }) + } + /> + )} + /> + )} + )} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index 0fbdaea39..443ff6d13 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -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. diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx new file mode 100644 index 000000000..56a89e261 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx @@ -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 { + 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 { + 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([]); + const [searching, setSearching] = useState(false); + const [resolving, setResolving] = useState(false); + const abortRef = useRef(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 ( + + + + } + rightSection={searching || resolving ? : null} + onChange={(e) => { + setQuery(e.currentTarget.value); + combobox.openDropdown(); + }} + onFocus={() => results.length > 0 && combobox.openDropdown()} + /> + + + + + {searching ? ( + Searching… + ) : results.length === 0 ? ( + + {query.trim().length < 3 + ? "Type at least 3 characters" + : "No matching places"} + + ) : ( + results.map((r, i) => ( + selectResult(r)} + > + + {r.displayName} + + + )) + )} + + + + + + + + + + {hasPin && ( + + )} + + + + + + {hasPin + ? value.address || "Pinned location" + : "Search above or click the map to drop a pin."} + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts index 612ec2ad8..4f6a1647b 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts @@ -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 = { 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>> = { 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 { diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx index 1df664ff8..1aa366074 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx @@ -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 ──────────────────────────────────────── diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx index b53571c03..fd979d570 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx @@ -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 && ( - ( - - )} - /> + + You’ll pick the exact pick-up location on the map in the + Route step. + )} )} @@ -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 && ( - ( - - )} - /> + + You’ll pick the exact delivery location on the map in the + Route step. + )} )} @@ -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 && ( + ( + + )} + /> + )} + )} /> )} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx index 410c2b48f..7959a3373 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx @@ -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(); - 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 ( )} + {showRouteQuantity && ( + + ( + field.onChange(String(v ?? ""))} + radius="md" + /> + )} + /> + + )} )} @@ -196,8 +224,8 @@ export function Step4Route({ 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. {extraRoutes.map((rf, i) => ( @@ -239,15 +267,16 @@ export function Step4Route({ )} /> - + ( field.onChange(String(v ?? ""))} radius="md" @@ -271,20 +300,76 @@ export function Step4Route({ )} - {direction && direction !== "DOMESTIC" && ( - ( - - )} - /> + {(showFirstMile || showLastMile) && ( + + Trucking locations + + Search for an address or click the map to drop a pin for your + door-to-port and port-to-door trucking. + + + {showFirstMile && ( + ( + + field.onChange({ + ...field.value, + enabled: true, + pickUpAddress: loc.address, + lat: loc.lat, + lng: loc.lng, + }) + } + /> + )} + /> + )} + {showLastMile && ( + ( + + field.onChange({ + ...field.value, + enabled: true, + deliveryAddress: loc.address, + lat: loc.lat, + lng: loc.lng, + }) + } + /> + )} + /> + )} + + )} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx index 9765d1950..80b17f16e 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx @@ -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 && ( 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}`} /> - @@ -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" /> diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index d00208d7b..e3b585596 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -128,7 +128,7 @@ export const api = { companiesService.updateProfile, ), - getDashboard: endpoint( + getDashboard: endpoint( "companies", "getDashboard", companiesService.getDashboard, diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 22d9ac92a..d265ebd6c 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -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; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80b94aa54..610821e53 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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