mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -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());
|
||||
|
||||
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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='© <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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ────────────────────────────────────────
|
||||
|
||||
@@ -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">
|
||||
You’ll 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">
|
||||
You’ll 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>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -128,7 +128,7 @@ export const api = {
|
||||
companiesService.updateProfile,
|
||||
),
|
||||
|
||||
getDashboard: endpoint<string | void, DashboardSummary>(
|
||||
getDashboard: endpoint<string | undefined, DashboardSummary>(
|
||||
"companies",
|
||||
"getDashboard",
|
||||
companiesService.getDashboard,
|
||||
|
||||
@@ -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
86
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user