From 15ab9f906e4ee1cf3c33c2f6371ed6a0b0da2e03 Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 24 Jun 2026 23:37:55 +0000 Subject: [PATCH 01/21] feat(bookings): enhance booking process with customs clearing and document handling - Update bookings service to prioritize uploaded documents over profile snapshots. - Refactor pricing data seeder to remove unused service types and streamline cargo type seeding. - Add customs clearing information to BookingRouteServiceCard, displaying agent details if applicable. - Extend BookingDetail type to include customs clearing options. - Modify NewBookingPage to remove the scheduling step, integrating estimated shipment date into the route step. - Update StepIndicator to reflect the new step structure. - Revise document handling in StepDocuments to allow for user uploads while displaying onboarding documents. - Adjust Step2ServiceType to manage customs clearing agent input based on service type. - Implement shipment date input in Step4Route for one-time bookings. - Revise Step8Review to reflect changes in document handling and scheduling. --- .../src/modules/bookings/bookings.service.ts | 6 +- .../src/seed/pricing-data.seeder.ts | 152 ++++++++---------- .../detail/BookingRouteServiceCard.tsx | 45 +++++- .../backoffice/src/types/booking.ts | 4 +- .../src/pages/bookings/NewBookingPage.tsx | 149 +++++++++++++++-- .../new-booking-form/StepIndicator.tsx | 8 +- .../pages/bookings/new-booking-form/schema.ts | 4 +- .../new-booking-form/step-documents.tsx | 139 ++++++++++------ .../new-booking-form/step2-service-type.tsx | 134 ++++++++++----- .../bookings/new-booking-form/step4-route.tsx | 96 +++++++++-- .../new-booking-form/step8-review.tsx | 53 +++--- 11 files changed, 566 insertions(+), 224 deletions(-) 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 45d2fbf7c..e05a3b35a 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -480,7 +480,11 @@ export class BookingsService { // Reuse the booking profile's onboarding documents instead of asking the // customer to re-upload. Snapshot them onto the booking now (by reference), // so a later active-profile switch never changes this booking's documents. - if (companyProfileId) { + // + // Skip this when the customer uploaded documents for this booking — those + // per-booking files take precedence, so auto-attaching the profile snapshots + // would create duplicates. + if (companyProfileId && files.length === 0) { try { const onboardingFiles = await this.companiesService.getProfileOnboardingFiles(companyProfileId); diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts index 6f44baaef..d4694d78f 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -32,7 +32,7 @@ export class PricingDataSeeder { const prRepo = manager.getRepository(PriorityConfig); const rRepo = manager.getRepository(Rate); - await this.upsertReferenceData(manager, ctRepo, stRepo, yRepo, slRepo); + await this.upsertReferenceData(manager, ctRepo, yRepo, slRepo); await this.seedDomesticRoute(manager, yRepo); await this.seedWeightLimits(wlRepo, ctRepo); await this.seedPriorityConfigs(prRepo); @@ -71,7 +71,6 @@ export class PricingDataSeeder { private async upsertReferenceData( manager: any, ctRepo: any, - stRepo: any, yRepo: any, slRepo: any, ): Promise { @@ -155,47 +154,7 @@ export class PricingDataSeeder { { conflictPaths: { code: true } }, ); - await stRepo.upsert( - [ - { - code: "RAIL_CONTAINER", - serviceName: "Rail Container Service", - description: "Standard rail container transport", - canBeBookedAlone: true, - includesFirstMile: false, - includesLastMile: false, - includesCustoms: false, - priorityBonusPoints: 0, - isActive: true, - displayOrder: 1, - }, - { - code: "RAIL_FORWARDING", - serviceName: "Rail Forwarding Service", - description: "Rail transport with first/last mile and customs", - canBeBookedAlone: true, - includesFirstMile: true, - includesLastMile: true, - includesCustoms: true, - priorityBonusPoints: 15, - isActive: true, - displayOrder: 2, - }, - { - code: "RAIL_BULK", - serviceName: "Rail Bulk Transport", - description: "Bulk commodity rail transport", - canBeBookedAlone: true, - includesFirstMile: false, - includesLastMile: false, - includesCustoms: false, - priorityBonusPoints: 10, - isActive: true, - displayOrder: 3, - }, - ], - { conflictPaths: { code: true } }, - ); + await slRepo.upsert( [ @@ -238,53 +197,78 @@ export class PricingDataSeeder { { conflictPaths: { code: true } }, ); - await manager.getRepository(CargoType).upsert( + await this.seedCargoTypes(manager); + } + + /** + * Cargo types are a fixed two-level tree: two top-level groups — Bulk and + * Break Bulk — each with a set of commodity children. The groups are the + * stable parents the booking wizard renders; children carry the + * unit_of_measure used when reserving quantity (PER_TON for bulk commodities, + * PER_ITEM for break-bulk items like vehicles/machinery). + * + * Parents are upserted first, then re-read by code to resolve their ids so the + * children can be linked via parent_group_id (upsert doesn't return ids). + */ + private async seedCargoTypes(manager: any): Promise { + const repo = manager.getRepository(CargoType); + + const groups = [ + { code: "BULK", cargoTypeName: "Bulk", displayOrder: 1 }, + { code: "BREAK_BULK", cargoTypeName: "Break Bulk", displayOrder: 2 }, + ]; + await repo.upsert( + groups.map((g) => ({ ...g, isActive: true })), + { conflictPaths: { code: true } }, + ); + + const bulk = await repo.findOneBy({ code: "BULK" }); + const breakBulk = await repo.findOneBy({ code: "BREAK_BULK" }); + if (!bulk || !breakBulk) return; + + // Bulk commodities — measured by tonnage (PER_TON). + const bulkChildren = [ + { code: "SUGAR", cargoTypeName: "Sugar" }, + { code: "GRAIN", cargoTypeName: "Grain / Cereals" }, + { code: "WHEAT", cargoTypeName: "Wheat" }, + { code: "FERTILIZER", cargoTypeName: "Fertilizer" }, + { code: "CEMENT", cargoTypeName: "Cement / Clinker" }, + { code: "COAL", cargoTypeName: "Coal" }, + ]; + + // Break-bulk items — counted as whole units (PER_ITEM). + const breakBulkChildren = [ + { code: "CARS", cargoTypeName: "Cars / Vehicles" }, + { code: "MACHINERY", cargoTypeName: "Heavy Machinery" }, + { code: "STEEL", cargoTypeName: "Steel / Rebar" }, + { code: "PIPES", cargoTypeName: "Pipes" }, + { code: "TIMBER", cargoTypeName: "Timber" }, + ]; + + await repo.upsert( [ - { - code: "GRAIN", - cargoTypeName: "Grain / Cereals", - requiresDirectorApproval: false, + ...bulkChildren.map((c, i) => ({ + ...c, + parentGroupId: bulk.id, + unitOfMeasure: "PER_TON", isActive: true, - displayOrder: 1, - }, - { - code: "FERTILIZER", - cargoTypeName: "Fertilizer", - requiresDirectorApproval: false, + displayOrder: i + 1, + })), + ...breakBulkChildren.map((c, i) => ({ + ...c, + parentGroupId: breakBulk.id, + unitOfMeasure: "PER_ITEM", isActive: true, - displayOrder: 2, - }, - { - code: "CEMENT", - cargoTypeName: "Cement / Clinker", - requiresDirectorApproval: false, - isActive: true, - displayOrder: 3, - }, - { - code: "STEEL", - cargoTypeName: "Steel / Rebar", - requiresDirectorApproval: true, - isActive: true, - displayOrder: 4, - }, - { - code: "MACHINERY", - cargoTypeName: "Heavy Machinery", - requiresDirectorApproval: true, - isActive: true, - displayOrder: 5, - }, - { - code: "OTHER_BULK", - cargoTypeName: "Other Bulk Cargo", - requiresDirectorApproval: false, - isActive: true, - displayOrder: 6, - }, + displayOrder: i + 1, + })), ], { conflictPaths: { code: true } }, ); + + // Retire the old flat "Other Bulk Cargo" top-level type from earlier seeds so + // it no longer shows alongside the Bulk / Break Bulk groups. No-op on a fresh + // DB where it was never seeded. + await repo.update({ code: "OTHER_BULK" }, { isActive: false }); } private async seedDomesticRoute(manager: any, yRepo: any): Promise { diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx index a4976ec61..67851ab30 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx @@ -1,4 +1,4 @@ -import { Train, MapPin, ArrowRight } from "lucide-react"; +import { Train, MapPin, ArrowRight, FileText } from "lucide-react"; import { Group, Stack, Text, Badge, Box, SimpleGrid } from "@mantine/core"; import type { BookingDetail } from "@/types/booking"; @@ -44,6 +44,8 @@ export function BookingRouteServiceCard({ const serviceLabel = booking.serviceType?.label ?? booking.serviceType?.code ?? "Rail service"; + const includesCustoms = booking.serviceType?.includesCustoms; + const metrics = [ { label: "Trade direction", value: booking.tradeDirection }, { label: "Freight type", value: booking.freightType }, @@ -96,6 +98,47 @@ export function BookingRouteServiceCard({ ))} + + {includesCustoms ? ( + + + + + Customs clearing included automatically + + + + ) : booking.customsClearingAgent ? ( + + + + + Customs clearing agent:{" "} + + {booking.customsClearingAgent} + + + + + ) : null} ); } diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index e8e6a6912..12648c2ad 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -157,6 +157,8 @@ export interface BookingDetail { firstMilePickupAddress?: string | null; lastMileDeliveryAddress?: string | null; equipmentReturn?: string; + customsClearingEnabled?: boolean; + customsClearingAgent?: string | null; contractSummary?: string | null; latestChangeRequestNote?: string | null; nextStep?: BookingNextStep | null; @@ -167,7 +169,7 @@ export interface BookingDetail { company?: BookingNamedRef & Partial; originYard?: BookingNamedRef; destinationYard?: BookingNamedRef; - serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number }; + serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number; includesCustoms?: boolean }; cargoType?: BookingNamedRef; shippingLine?: BookingNamedRef; bookingContainers?: BookingContainerLine[]; 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 22dd92dd7..0aa9336fa 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -53,11 +53,22 @@ import { Step5CargoDetails, Step8Review, StepDocuments, - StepScheduling, } from "./new-booking-form/steps"; type PriceModalMode = "submit" | "draft"; +/** Human-readable label for a rate's charge unit (e.g. "per container"). */ +function formatPriceUnit(unit: string): string { + const map: Record = { + PER_CONTAINER: "per container", + PER_TON: "per ton", + PER_WAGON: "per wagon", + PER_KM: "per km", + FLAT: "flat", + }; + return map[unit] ?? unit.replace(/_/g, " ").toLowerCase(); +} + export default function NewBookingPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); @@ -238,15 +249,11 @@ export default function NewBookingPage() { const originYard = form.watch("originYard"); const destinationYard = form.watch("destinationYard"); - const bookingType = form.watch("bookingType"); - const isGeneralContract = bookingType === "general_contract"; - // General contracts have no shipment date at creation — the Schedule step - // (id 5) is skipped; the date is chosen per order against the contract later. - const visibleSteps = useMemo( - () => STEPS.filter((s) => !(isGeneralContract && s.id === 5)), - [isGeneralContract], - ); + // The estimated shipment date lives in the Route step now; for general + // contracts that date field is simply hidden there (the date is chosen per + // order against the contract later). No dedicated schedule step remains. + const visibleSteps = useMemo(() => STEPS, []); const visibleStepIds = useMemo( () => visibleSteps.map((s) => s.id), [visibleSteps], @@ -633,10 +640,7 @@ export default function NewBookingPage() { isLoading={refDataLoading} /> )} - {step === 5 && ( - - )} - {step === 6 && } + {step === 6 && } {step === 7 && ( + {pricingData.lineItems.length > 0 && ( + + + Price breakdown + + + {pricingData.lineItems.map((item) => { + const hasUnit = + item.unitAmount != null && + item.quantity != null && + item.quantity > 0; + return ( + + + + {item.description} + + {hasUnit && ( + + {item.quantity!.toLocaleString()} ×{" "} + {item.unitAmount!.toLocaleString()} {item.currency} + {item.unit + ? ` · ${formatPriceUnit(item.unit)}` + : ""} + + )} + + + {item.amount.toLocaleString()} {item.currency} + + + ); + })} + + + )} + {priceChangeResult.lineItems && + priceChangeResult.lineItems.length > 0 && ( + + + Price breakdown + + + {priceChangeResult.lineItems.map((item) => { + const hasUnit = + item.unitAmount != null && + item.quantity != null && + item.quantity > 0; + return ( + + + + {item.description} + + {hasUnit && ( + + {item.quantity!.toLocaleString()} ×{" "} + {item.unitAmount!.toLocaleString()}{" "} + {item.currency} + {item.unit + ? ` · ${formatPriceUnit(item.unit)}` + : ""} + + )} + + + {item.amount.toLocaleString()} {item.currency} + + + ); + })} + + + )} From 6ab9699c947e68fed1c1410704065d360a9cc163 Mon Sep 17 00:00:00 2001 From: marshal Date: Thu, 25 Jun 2026 03:01:56 +0300 Subject: [PATCH 03/21] refactor(first-mile): update module imports to use forwardRef for circular dependency resolution --- .../src/modules/first-mile/first-mile.module.ts | 4 ++-- apps/edr-freight-api/src/modules/payment/payment.module.ts | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts index 713efa52d..382499da8 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BookingsModule } from '../bookings/bookings.module'; @@ -8,7 +8,7 @@ import { FirstMileRepository } from './first-mile.repository'; import { FirstMileService } from './first-mile.service'; @Module({ - imports: [TypeOrmModule.forFeature([FirstMile]), BookingsModule], + imports: [TypeOrmModule.forFeature([FirstMile]), forwardRef(() => BookingsModule)], controllers: [FirstMileController], providers: [FirstMileRepository, FirstMileService], exports: [FirstMileRepository, FirstMileService], diff --git a/apps/edr-freight-api/src/modules/payment/payment.module.ts b/apps/edr-freight-api/src/modules/payment/payment.module.ts index e496bb867..d0eb2eecb 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.module.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.module.ts @@ -19,6 +19,7 @@ import { InternalPaymentController } from "./internal-payment.controller"; import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module"; import { DropdownSettingsModule } from "../dropdown-settings/dropdown-settings.module"; +import { FirstMileModule } from "../first-mile/first-mile.module"; import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity"; import { PaymentRefundEntity } from "./entities/payment-refund.entity"; @@ -29,6 +30,7 @@ const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT]; HttpModule.register({ timeout: 10_000 }), ConfigModule, DropdownSettingsModule, + forwardRef(() => FirstMileModule), forwardRef(() => TrainSchedulingModule), TypeOrmModule.forFeature([PaymentWebhookEventEntity, PaymentRefundEntity]), RabbitMQModule.forRootAsync({ From 08977fcd19783576ee45f34f006e82dd0a290f16 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 25 Jun 2026 00:28:54 +0000 Subject: [PATCH 04/21] feat(bookings): add estimated shipment date handling and validation for binding shipment day --- .../1820000000012-AddEstimatedShipmentDate.ts | 26 ++ .../bookings/booking-transition.service.ts | 14 + .../src/modules/bookings/bookings.service.ts | 36 ++- .../bookings/dto/create-booking.dto.ts | 16 +- .../bookings/entities/booking.entity.ts | 13 + .../components/ClearanceCard.tsx | 245 +++++++++++++++++- .../src/pages/bookings/NewBookingPage.tsx | 11 +- .../new-booking-form/step2-service-type.tsx | 96 ++++++- .../portal/src/services/api.ts | 8 +- .../portal/src/services/bookings.service.ts | 10 +- packages/types/src/freight/index.ts | 4 +- 11 files changed, 452 insertions(+), 27 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1820000000012-AddEstimatedShipmentDate.ts diff --git a/apps/edr-freight-api/src/migrations/1820000000012-AddEstimatedShipmentDate.ts b/apps/edr-freight-api/src/migrations/1820000000012-AddEstimatedShipmentDate.ts new file mode 100644 index 000000000..6b77f53a3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1820000000012-AddEstimatedShipmentDate.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * The booking wizard now captures a NON-BINDING estimated shipment date instead + * of the binding scheduledDate. The binding scheduledDate (validated against + * open train departures) is set later, at the operation-request step. + */ +export class AddEstimatedShipmentDate1820000000012 + implements MigrationInterface +{ + name = 'AddEstimatedShipmentDate1820000000012'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS estimated_shipment_date timestamptz NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS estimated_shipment_date; + `); + } +} 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 2c2e0ce0e..b85f55206 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 @@ -795,6 +795,20 @@ export class BookingTransitionService { throw new BadRequestException('A valid schedule date is required'); } + // The binding shipment day must have at least one OPEN departure on the + // route — only schedule-backed days are selectable. The batch engine + // assigns the specific train within that (route, day) pool later. + const hasDeparture = await this.bookingsService.hasOpenDepartureOnDay( + booking.originYardId, + booking.destinationYardId, + eatDay(date), + ); + if (!hasDeparture) { + throw new BadRequestException( + 'No departures available on the selected day for this route', + ); + } + await this.bookingsRepository.update(bookingId, { status: 'OPERATION_REQUEST_PENDING', scheduledDate: date, 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 486e7eae3..699d6ac22 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -317,12 +317,14 @@ export class BookingsService { ) { throw new BadRequestException('Selected schedule is not on the booking route'); } - } else if (!isGeneralContract) { - // Day-level pool: the customer picked a DAY — require that the route has at - // least one OPEN departure on that EAT day. The batch engine assigns the - // train later. General contracts skip this — they have no shipment date at - // creation; each drawdown order validates its own day. - const day = eatDay(new Date(dto.scheduledDate!)); + } else if (dto.scheduledDate) { + // A real (binding) scheduledDate was supplied (e.g. staff pinning a day + // directly). Require that the route has at least one OPEN departure on + // that EAT day. The booking wizard does NOT send scheduledDate at creation + // — it captures a non-binding estimatedShipmentDate instead, and the + // binding day is chosen later at the operation-request step. General + // contracts also skip this (each drawdown order validates its own day). + const day = eatDay(new Date(dto.scheduledDate)); const hasDeparture = await this.trainSchedulingService.existsOpenScheduleOnRouteDay( dto.originYardId, @@ -428,6 +430,9 @@ export class BookingsService { financialTerms: dto.financialTerms, bookingType: isGeneralContract ? 'GENERAL_CONTRACT' : 'ONE_TIME', scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null, + estimatedShipmentDate: dto.estimatedShipmentDate + ? new Date(dto.estimatedShipmentDate) + : null, startDate: dto.startDate ? new Date(dto.startDate) : undefined, endDate: dto.endDate ? new Date(dto.endDate) : undefined, status: 'DRAFT', @@ -621,6 +626,8 @@ export class BookingsService { ); } if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate); + if (dto.estimatedShipmentDate) + updates.estimatedShipmentDate = new Date(dto.estimatedShipmentDate); if (dto.startDate) updates.startDate = new Date(dto.startDate); if (dto.endDate) updates.endDate = new Date(dto.endDate); delete updates.containers; @@ -696,6 +703,23 @@ export class BookingsService { } /** Return a paginated list of bookings matching the filter. */ + /** + * Whether a route has at least one OPEN train departure on the given EAT day. + * Used to validate the binding shipment day chosen at the operation-request + * step (only days with a schedule are selectable). + */ + async hasOpenDepartureOnDay( + originYardId: string, + destinationYardId: string, + day: string, + ): Promise { + return this.trainSchedulingService.existsOpenScheduleOnRouteDay( + originYardId, + destinationYardId, + day, + ); + } + async findAll( filter: FilterBookingDto, forceCompanyId?: string, 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 e66c85522..7f420d3f1 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 @@ -155,14 +155,24 @@ export class CreateBookingDto { bookingType?: string; /** - * The day the customer wants to ship (the pool day key). Required for one-time - * bookings; omitted for general contracts, which pick the date per order. + * The BINDING shipment day (the pool day key), validated against open train + * departures. Set later at the operation-request step — NOT at booking + * creation. Optional here; staff may still pin it directly. */ @ApiPropertyOptional({ example: '2026-06-15T00:00:00.000Z' }) - @ValidateIf((o) => o.bookingType !== 'GENERAL_CONTRACT') + @IsOptional() @IsDateString() scheduledDate?: string; + /** + * Non-binding shipment-date estimate captured in the booking wizard. Purely + * informational — NOT validated against train departures. + */ + @ApiPropertyOptional({ example: '2026-06-15T00:00:00.000Z' }) + @IsOptional() + @IsDateString() + estimatedShipmentDate?: string; + @ApiProperty({ enum: CONTRACT_TYPES }) @IsIn([...CONTRACT_TYPES]) contractType!: string; 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 5693cfaf5..00f6e41c1 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 @@ -155,10 +155,23 @@ export class Booking extends BaseEntity { /** * Nullable: general contracts have no shipment date at creation — the date is * chosen per drawdown order. One-time bookings always set this (the pool day key). + * + * NOTE: this is the BINDING shipment day, validated against actual open train + * departures. It is set later, when the customer requests the operation — NOT + * at booking creation. See estimatedShipmentDate for the non-binding estimate + * captured in the booking wizard. */ @Column({ name: 'scheduled_date', type: 'timestamptz', nullable: true }) scheduledDate?: Date | null; + /** + * Non-binding shipment-date estimate captured in the booking wizard. Purely + * informational — NOT validated against train departures. The binding + * scheduledDate is chosen later at the operation-request step. + */ + @Column({ name: 'estimated_shipment_date', type: 'timestamptz', nullable: true }) + estimatedShipmentDate?: Date | null; + /** * General contracts only: when the ordering window closes, computed from the * global CONTRACT_PERIOD_MONTHS setting at activation. Null for one-time diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx index 59ab71ff9..e54b03be1 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx @@ -9,9 +9,24 @@ import { TextInput, } from "@mantine/core"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + addMonths, + eachDayOfInterval, + endOfMonth, + endOfWeek, + format, + isSameMonth, + isToday, + startOfMonth, + startOfWeek, +} from "date-fns"; import { AlertCircle, + Calendar as CalendarIcon, + Check, CheckCircle2, + ChevronLeft, + ChevronRight, Clock, Download, FileText, @@ -86,6 +101,8 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) { const [adHoc, setAdHoc] = useState>( [], ); + // Binding shipment day chosen for the operation request (yyyy-MM-dd). + const [scheduledDate, setScheduledDate] = useState(""); const refresh = () => { queryClient.invalidateQueries({ @@ -339,6 +356,32 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) { )} + {isReady && ( + + + Choose your shipment day + + + Only days with a scheduled departure on your route can be selected. + The operations team assigns the specific train for that day. + + + + )} + + {proceedMutation.isError && ( + } mt="md"> + {proceedMutation.error instanceof Error + ? proceedMutation.error.message + : "Could not request the operation. Please try again."} + + )} + {canUpload && ( @@ -375,3 +419,202 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) { ); } + +/** + * Compact month calendar for picking the binding shipment day at the + * operation-request step. Only days that have an OPEN scheduled departure on the + * booking route are selectable; all other days are disabled. + */ +function OperationDatePicker({ + originYardId, + destinationYardId, + value, + onChange, +}: { + originYardId?: string; + destinationYardId?: string; + value: string; + onChange: (date: string) => void; +}) { + const [month, setMonth] = useState(() => startOfMonth(new Date())); + + const { data: availableDays, isLoading } = useQuery( + api.bookings.getAvailableDays.queryOptions({ + input: { originYardId, destinationYardId }, + enabled: !!originYardId && !!destinationYardId, + }), + ); + + const departureDays = useMemo( + () => new Set(availableDays ?? []), + [availableDays], + ); + + const cells = useMemo(() => { + const start = startOfWeek(startOfMonth(month), { weekStartsOn: 1 }); + const end = endOfWeek(endOfMonth(month), { weekStartsOn: 1 }); + return eachDayOfInterval({ start, end }).map((date) => { + const dateString = format(date, "yyyy-MM-dd"); + return { + date, + dateString, + day: date.getDate(), + inMonth: isSameMonth(date, month), + today: isToday(date), + selected: value === dateString, + hasDeparture: departureDays.has(dateString), + }; + }); + }, [month, departureDays, value]); + + return ( + + + + + {format(month, "MMMM yyyy")} + + + + + {isLoading ? ( + + + + Loading available days… + + + ) : ( + <> + + {["M", "T", "W", "T", "F", "S", "S"].map((d, i) => ( + + {d} + + ))} + + + {cells.map((c) => { + const clickable = c.hasDeparture && c.inMonth; + return ( + + ); + })} + + {value && ( + + Selected: {format(new Date(value + "T00:00:00"), "EEE, MMM d yyyy")} + + )} + {!isLoading && departureDays.size === 0 && ( + + No scheduled departures found for this route yet. + + )} + + )} + + ); +} 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 53fff0f1b..9ce4f4194 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -422,13 +422,14 @@ export default function NewBookingPage() { bookingType: isContract ? Freight.BookingType.GeneralContract : Freight.BookingType.OneTime, - // General contracts omit the shipment date — chosen per order later. - ...(isContract + // The wizard captures a NON-BINDING estimate only — never the binding + // scheduledDate (that is chosen later at the operation-request step and + // validated against open departures). General contracts omit even the + // estimate; the date is chosen per order later. + ...(isContract || !data.scheduledDate ? {} : { - scheduledDate: data.scheduledDate - ? new Date(data.scheduledDate).toISOString() - : new Date().toISOString(), + estimatedShipmentDate: new Date(data.scheduledDate).toISOString(), }), contractType: data.contractType.toUpperCase() as CreateBookingPayload["contractType"], 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 d11d09415..2b72393f6 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 @@ -1,12 +1,11 @@ import { Box, Group, Stack, Switch, Text, TextInput } from "@mantine/core"; import type { ReactNode } from "react"; -import { FileText, Info, Layers, Train, Truck } from "lucide-react"; +import { Check, FileText, Info, Layers, Train, Truck } from "lucide-react"; import { useEffect, useRef } from "react"; import { Controller, type UseFormReturn } from "react-hook-form"; import { BookingFormInputValues, type BookingFormValues } from "./schema"; import { fieldStyles, - OptionCard, OptionFieldError, StepCard, StepHeader, @@ -88,17 +87,14 @@ export function Step2ServiceType({ control={form.control} render={({ field, fieldState }) => (
-
+
{referenceData?.service .filter((s) => s.canBeBookedAlone) .map((s) => ( - field.onChange(s.id)} - icon={} - iconBg="#EEF0FB" - iconColor="#4F46E5" title={s.serviceName} description={s.description} /> @@ -365,6 +361,92 @@ export function Step2ServiceType({ ); } +/** + * Compact service-type selection card. A single horizontal row (icon · text · + * radio) — deliberately smaller than the shared OptionCard so the service list + * stays scannable. + */ +function ServiceTypeCard({ + selected, + onClick, + title, + description, +}: { + selected: boolean; + onClick: () => void; + title?: ReactNode; + description?: ReactNode; +}) { + return ( + + ); +} + function ServiceToggle({ icon, title, diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index a8f76a1bf..6ba3aa84b 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -269,10 +269,14 @@ export const api = { bookingsService.submitClearanceDocuments(id, files), ), - proceedToOperation: endpoint<{ id: string }, Freight.IBooking>( + proceedToOperation: endpoint< + { id: string; scheduledDate: string }, + Freight.IBooking + >( "bookings", "proceedToOperation", - ({ id }) => bookingsService.proceedToOperation(id), + ({ id, scheduledDate }) => + bookingsService.proceedToOperation(id, scheduledDate), ), checkPayment: endpoint<{ orderId: string }, { status: string }>( diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index 2cebf6fea..6a8c76795 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -206,8 +206,14 @@ export const bookingsService = { return data.data; }, - proceedToOperation: async (id: string): Promise => { - const { data } = await client.post(`/api/bookings/${id}/clearance/proceed`); + proceedToOperation: async ( + id: string, + scheduledDate: string, + ): Promise => { + const { data } = await client.post( + `/api/bookings/${id}/clearance/proceed`, + { scheduledDate }, + ); return data.data; }, diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index adb1e85a5..f9b5ac92f 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -646,8 +646,10 @@ export interface CreateBookingDto { companyId?: string | undefined; trainId?: string | undefined; trainScheduleId?: string | undefined; - /** Optional for general contracts — they pick the date per order, not at creation. */ + /** Binding shipment day — set at the operation-request step, not at creation. */ scheduledDate?: string | undefined; + /** Non-binding shipment-date estimate captured in the booking wizard. */ + estimatedShipmentDate?: string | undefined; /** Defaults to ONE_TIME. GENERAL_CONTRACT creates an umbrella contract. */ bookingType?: BookingType | undefined; contractType: string; From c8377958a890e360fbd565f3bbdb9d16d9865aa5 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 25 Jun 2026 00:48:14 +0000 Subject: [PATCH 05/21] fix(routes): prevent synthetic event recycling issue in name input handler --- .../backoffice/src/pages/fleet/RoutesPage.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx index 7d530697f..30acf50ba 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx @@ -408,7 +408,13 @@ export default function RoutesPage() { setForm((current) => ({ ...current, name: e.currentTarget.value }))} + onChange={(e) => { + // Capture the value before the state updater runs — React may + // recycle the synthetic event, nulling currentTarget by the time + // the updater executes ("Cannot read properties of null"). + const name = e.currentTarget.value; + setForm((current) => ({ ...current, name })); + }} /> From 23b353999a9ac2e8a235ff3a0ba0c61edc4a78cd Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 25 Jun 2026 01:28:45 +0000 Subject: [PATCH 06/21] feat: enhance cargo details handling and add document clearance features - Improved handling of commodity selection in the cargo details form to prevent unwanted resets on re-renders. - Added `isReefer` flag to the CreateBookingDto interface for booking-level refrigerated status. - Introduced a new configuration file for clearance tabs to manage document clearance views. - Implemented DocumentClearanceDetailPage for detailed review and management of clearance documents. - Created DocumentClearanceListPage for listing and filtering clearance bookings with enhanced UI components. --- .../bookings/booking-pricing.service.ts | 6 + .../src/modules/bookings/bookings.service.ts | 20 + .../bookings/dto/create-booking.dto.ts | 11 + .../rule-engine/rule-engine.service.ts | 54 +- .../src/seed/pricing-data.seeder.ts | 8 +- apps/edr-freight-web/backoffice/src/App.tsx | 13 +- .../clearance/clearance-tabs.config.ts | 26 + .../bookings/DocumentClearanceDetailPage.tsx | 731 +++++++++++++++++ .../bookings/DocumentClearanceListPage.tsx | 615 ++++++++++++++ .../src/pages/bookings/GlClearancePage.tsx | 765 ------------------ .../src/pages/bookings/NewBookingPage.tsx | 22 +- .../bookings/new-booking-form/shared.tsx | 6 +- .../bookings/new-booking-form/step4-route.tsx | 43 +- .../new-booking-form/step5-cargo-details.tsx | 22 +- packages/types/src/freight/index.ts | 2 + 15 files changed, 1545 insertions(+), 799 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/features/clearance/clearance-tabs.config.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceListPage.tsx delete mode 100644 apps/edr-freight-web/backoffice/src/pages/bookings/GlClearancePage.tsx diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 0315de60d..f9d8fb17e 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -276,6 +276,12 @@ export class BookingPricingService { allowConsolidation, shippingLineId: booking.shippingLineId, totalWagons, + // Bulk tonnage scales PER_TON surcharges (e.g. the bulk reefer surcharge). + // Container freight carries 0 here — its surcharges scale by container count. + bulkTons: + booking.freightType === 'BULK' + ? Number(booking.cargoTotalWeightVgm ?? 0) + : 0, containers, }; } 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 699d6ac22..9db946d26 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -126,8 +126,10 @@ export class BookingsService { paymentCurrency: string; tradeDirection: string; isHazardous?: boolean; + isReefer?: boolean; isGovernment?: boolean; shippingLineId?: string | null; + bulkTons?: number; containers: CreateBookingContainerDto[]; }): Promise { const containerLines = @@ -166,10 +168,14 @@ export class BookingsService { paymentCurrency: dto.paymentCurrency, tradeDirection: dto.tradeDirection, isHazardous: dto.isHazardous ?? false, + // Bulk reefer comes from the customer toggle; container reefer is derived + // from the container type and ORed in by the engine. + isReefer: dto.freightType === 'BULK' ? (dto.isReefer ?? false) : false, isGovernment: dto.isGovernment ?? false, allowConsolidation, shippingLineId: dto.shippingLineId, totalWagons, + bulkTons: dto.freightType === 'BULK' ? Number(dto.bulkTons ?? 0) : 0, containers, }; } @@ -387,8 +393,10 @@ export class BookingsService { paymentCurrency: dto.paymentCurrency, tradeDirection, isHazardous: dto.isHazardous, + isReefer: dto.isReefer, isGovernment, shippingLineId: dto.shippingLineId, + bulkTons: dto.cargoTotalWeightVgm, containers, }); const ruleResult = await this.ruleEngineService.evaluate(evalInput); @@ -425,6 +433,10 @@ export class BookingsService { shippingLineId: dto.shippingLineId, cargoTotalWeightVgm: dto.cargoTotalWeightVgm, isHazardous: dto.isHazardous ?? false, + // Bulk reefer is the customer's toggle; container reefer is derived from + // the container type at pricing time, so the booking-level flag stays off + // for container freight to avoid double-counting. + isReefer: dto.freightType === 'BULK' ? (dto.isReefer ?? false) : false, paymentCurrency: dto.paymentCurrency, pnrCode: dto.pnrCode, financialTerms: dto.financialTerms, @@ -586,7 +598,9 @@ export class BookingsService { paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency, tradeDirection, isHazardous: dto.isHazardous ?? existing.isHazardous, + isReefer: dto.isReefer ?? existing.isReefer, shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined, + bulkTons: dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0), containers, }); @@ -606,6 +620,12 @@ export class BookingsService { ...dto, freightType, cargoTypeId: freightType === 'BULK' ? cargoTypeId : null, + // Booking-level reefer is only meaningful for bulk; container reefer is + // derived from the container type at pricing time. + isReefer: + freightType === 'BULK' + ? (dto.isReefer ?? existing.isReefer ?? false) + : false, priorityScore: ruleResult.priorityScore, tradeDirection, }; 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 7f420d3f1..ee5faa465 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 @@ -306,6 +306,17 @@ export class CreateBookingDto { @Transform(({ value }) => value === 'true' || value === true) isHazardous?: boolean; + /** + * Booking-level refrigerated flag. For bulk freight this is the customer's + * reefer choice (containers derive reefer from the container type instead). + * ORed with per-container reefer when the REEFER surcharge is evaluated. + */ + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === 'true' || value === true) + isReefer?: boolean; + @ApiProperty({ enum: PAYMENT_CURRENCIES }) @IsIn([...PAYMENT_CURRENCIES]) paymentCurrency!: string; diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index ce0082f83..16027f9c3 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -57,6 +57,12 @@ export interface BookingEvaluationInput { allowConsolidation?: boolean; shippingLineId?: string | null; totalWagons: number; + /** + * Total bulk tonnage on the booking (cargoTotalWeightVgm). Used to scale + * PER_TON surcharges (e.g. the bulk reefer surcharge). 0/undefined for + * container freight, which is scaled by container count instead. + */ + bulkTons?: number; containers: BookingContainerEvalInput[]; } @@ -224,16 +230,46 @@ export class RuleEngineService { }); if (!triggered) continue; - let triggerValue: number | null = null; - let calculatedAmount = Number(rate.rateValue); + // Surcharges scale by their own rateUnit, so the same trigger can bill the + // right way per freight shape — e.g. a PER_TON reefer rate multiplies the + // bulk tonnage, while a PER_CONTAINER reefer rate multiplies the container + // count. triggerValue records the quantity billed (shown on the breakdown). + const rateValue = Number(rate.rateValue); + const containerCount = input.containers.reduce( + (sum, c) => sum + Number(c.quantity || 0), + 0, + ); + const overweightExcessTons = containerWeightResults.reduce( + (sum, r) => sum + (r.overweightExcessTons ?? 0), + 0, + ); - // Per-ton surcharges (typically OVERWEIGHT) bill against the excess tons. - if (rate.rateUnit === 'PER_TON' && rate.trigger === 'OVERWEIGHT') { - triggerValue = containerWeightResults.reduce( - (sum, r) => sum + (r.overweightExcessTons ?? 0), - 0, - ); - calculatedAmount = triggerValue * Number(rate.rateValue); + let triggerValue: number | null = null; + let calculatedAmount: number; + + switch (rate.rateUnit) { + case 'PER_TON': + // OVERWEIGHT bills the excess tons; every other PER_TON surcharge + // (e.g. bulk reefer) bills the full bulk tonnage. + triggerValue = + rate.trigger === 'OVERWEIGHT' + ? overweightExcessTons + : Number(input.bulkTons ?? 0); + calculatedAmount = triggerValue * rateValue; + break; + case 'PER_CONTAINER': + triggerValue = containerCount; + calculatedAmount = triggerValue * rateValue; + break; + case 'PER_WAGON': + triggerValue = input.totalWagons; + calculatedAmount = triggerValue * rateValue; + break; + case 'FLAT': + default: + // FLAT (and any unknown unit) bills once. + calculatedAmount = rateValue; + break; } // Safety guard: never include a surcharge with a non-positive amount (a diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts index e4153c747..57fa1dc23 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -474,7 +474,13 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { // ── Surcharges (trigger-based) ────────────────────────────────────── { appliesTo: "OTHER", trigger: "OVERWEIGHT", rateType: "OVERWEIGHT_PER_TON", rateValue: 25, rateUnit: "PER_TON" }, { appliesTo: "OTHER", trigger: "HAZARDOUS", rateType: "HAZARD_SURCHARGE", rateValue: 150, rateUnit: "FLAT" }, - { appliesTo: "OTHER", trigger: "REEFER", rateType: "REEFER_SURCHARGE", rateValue: 200, rateUnit: "FLAT" }, + // Reefer surcharge scales with the freight shape: container bookings bill + // per reefer container, bulk bookings bill per ton. The engine now honors + // each rate's unit, so both rows can coexist — only the matching one + // produces a non-zero line (the other multiplies by 0 and is dropped). + // Small test values (< 20) so the surcharge stays a minor add for now. + { appliesTo: "OTHER", trigger: "REEFER", rateType: "REEFER_SURCHARGE", rateValue: 15, rateUnit: "PER_CONTAINER" }, + { appliesTo: "OTHER", trigger: "REEFER", rateType: "REEFER_SURCHARGE", rateValue: 2, rateUnit: "PER_TON" }, { appliesTo: "OTHER", trigger: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" }, { appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" }, ]; diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 05b976c85..7a14e3ffa 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -28,7 +28,8 @@ import LoginPage from "./pages/auth/LoginPage"; import BookingContractPage from "./pages/bookings/BookingContractPage"; import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; -import GlClearancePage from "./pages/bookings/GlClearancePage"; +import DocumentClearanceListPage from "./pages/bookings/DocumentClearanceListPage"; +import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage"; import NewBookingPage from "./pages/bookings/NewBookingPage"; import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; import CustomersPage from "./pages/customers/CustomersPage"; @@ -417,7 +418,15 @@ const App = () => { path="clearance" element={ - + + + } + /> + + } /> diff --git a/apps/edr-freight-web/backoffice/src/features/clearance/clearance-tabs.config.ts b/apps/edr-freight-web/backoffice/src/features/clearance/clearance-tabs.config.ts new file mode 100644 index 000000000..b0166aa3f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/features/clearance/clearance-tabs.config.ts @@ -0,0 +1,26 @@ +import type { LucideIcon } from "lucide-react"; +import { Layers, ShieldCheck, ShipWheel, Truck } from "lucide-react"; + +/** + * The document-clearance queue is a single backend status + * (`DOCUMENTS_UNDER_REVIEW`); the tabs slice that queue by the operational axis + * that matters to a clearance officer — trade direction and customs scope — + * rather than by booking status (which is uniform here). + */ +export type ClearanceTabKey = "all" | "import" | "export" | "customs"; + +export interface ClearanceTab { + key: ClearanceTabKey; + label: string; + icon: LucideIcon; +} + +export const CLEARANCE_TABS: ClearanceTab[] = [ + { key: "all", label: "All", icon: Layers }, + { key: "import", label: "Import", icon: Truck }, + { key: "export", label: "Export", icon: ShipWheel }, + { key: "customs", label: "With customs", icon: ShieldCheck }, +]; + +/** The backend booking status that places a booking in the clearance queue. */ +export const CLEARANCE_REVIEW_STATUS = "DOCUMENTS_UNDER_REVIEW"; diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx new file mode 100644 index 000000000..9109ff9fe --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx @@ -0,0 +1,731 @@ +import { useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigate, useParams } from "react-router-dom"; +import { + Alert, + Badge, + Box, + Button, + FileButton, + Grid, + Group, + Loader, + Paper, + Progress, + RingProgress, + Stack, + Text, + Textarea, + ThemeIcon, + Tooltip, +} from "@mantine/core"; +import { + AlertCircle, + ArrowRight, + CheckCircle2, + Clock, + Download, + ExternalLink, + FileCheck2, + FileText, + MessageSquareWarning, + PackageCheck, + ShieldCheck, + Upload, +} from "lucide-react"; +import toast from "react-hot-toast"; +import type { Freight } from "@edr/types"; + +import { PageContainer } from "@/components/page/PageContainer"; +import { PageHeader } from "@/components/page/PageHeader"; +import { SectionCard } from "@/components/bookings/detail"; +import { bookingsService } from "@/services/bookings.service"; +import { useBookingDetail } from "@/hooks/bookings/useBookings"; + +export default function DocumentClearanceDetailPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const qc = useQueryClient(); + + const { data: booking } = useBookingDetail(id); + const { + data: clearance, + isLoading, + isError, + } = useQuery({ + queryKey: ["clearance", id], + queryFn: () => bookingsService.getClearance(id!), + enabled: Boolean(id), + }); + + const [queryNotes, setQueryNotes] = useState>({}); + const [openQuery, setOpenQuery] = useState>({}); + const [outputFiles, setOutputFiles] = useState>({}); + + const refresh = () => { + qc.invalidateQueries({ queryKey: ["clearance", id] }); + qc.invalidateQueries({ queryKey: ["clearance", "list"] }); + }; + + const reviewMutation = useMutation({ + mutationFn: (p: { + fileKey: string; + status: "APPROVED" | "QUERIED"; + note?: string; + }) => bookingsService.reviewClearanceDocument(id!, p), + onSuccess: (_d, p) => { + toast.success( + p.status === "APPROVED" ? "Document approved" : "Query sent to customer", + ); + if (p.status === "QUERIED") + setOpenQuery((o) => ({ ...o, [p.fileKey]: false })); + refresh(); + }, + onError: () => toast.error("Could not update document"), + }); + + const outputMutation = useMutation({ + mutationFn: () => bookingsService.uploadClearanceOutput(id!, outputFiles), + onSuccess: () => { + toast.success("Output documents uploaded"); + setOutputFiles({}); + refresh(); + }, + onError: () => toast.error("Upload failed"), + }); + + const finalizeMutation = useMutation({ + mutationFn: () => bookingsService.finalizeClearance(id!), + onSuccess: () => { + toast.success("Clearance finalized"); + refresh(); + navigate("/dashboard/clearance"); + }, + onError: (e) => + toast.error( + e instanceof Error ? e.message : "Could not finalize clearance", + ), + }); + + const customerDocs = useMemo( + () => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"), + [clearance], + ); + const glDocs = useMemo( + () => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "gl"), + [clearance], + ); + + const stats = useMemo(() => { + const total = customerDocs.length; + const approved = customerDocs.filter( + (d) => d.reviewStatus === "APPROVED", + ).length; + const queried = customerDocs.filter( + (d) => d.reviewStatus === "QUERIED", + ).length; + const pending = total - approved - queried; + const pct = total === 0 ? 0 : Math.round((approved / total) * 100); + return { total, approved, queried, pending, pct }; + }, [customerDocs]); + + const reference = booking?.reference ?? "Clearance"; + + if (isLoading) { + return ( + + + + Loading clearance… + + + ); + } + + if (isError || !clearance) { + return ( + + + }> + We couldn’t load this booking’s clearance. + + + ); + } + + return ( + + + } + > + All approved + + ) : ( + } + > + Review pending + + ) + } + /> + + {/* Hero */} + + + + {/* LEFT — document review */} + + + + {stats.approved}/{stats.total} approved + + } + > + + {customerDocs.length === 0 ? ( + + No customer documents are required for this booking. + + ) : ( + customerDocs.map((doc) => ( + + setOpenQuery((o) => ({ ...o, [doc.fileKey]: open })) + } + onNote={(v) => + setQueryNotes((n) => ({ ...n, [doc.fileKey]: v })) + } + onApprove={() => + reviewMutation.mutate({ + fileKey: doc.fileKey, + status: "APPROVED", + }) + } + onQuery={() => + reviewMutation.mutate({ + fileKey: doc.fileKey, + status: "QUERIED", + note: queryNotes[doc.fileKey], + }) + } + busy={reviewMutation.isPending} + /> + )) + )} + + + + {clearance.outputCode && ( + + + {glDocs.map((doc) => ( + + + + + {doc.label} + {doc.required ? " *" : ""} + + + + {doc.file ? ( + + + + + + ) : ( + + Not uploaded + + )} + + f && + setOutputFiles((o) => ({ ...o, [doc.fileKey]: f })) + } + accept="application/pdf,image/*" + > + {(props) => ( + + )} + + + + ))} + + + + + + )} + + + + {/* RIGHT — sticky summary + finalize */} + + + + + + + + {stats.pct}% + + + approved + + + } + /> + + + + + + + + + {finalizeMutation.isError && ( + } + > + {finalizeMutation.error instanceof Error + ? finalizeMutation.error.message + : "Could not finalize clearance."} + + )} + + + + + + + + Finalize clearance + + + + {clearance.allApproved + ? "All required documents are approved — you can finalize." + : "Approve every required document to unlock finalization."} + + + + + + + + + + ); +} + +function ClearanceHero({ + booking, + clearance, + stats, +}: { + booking: ReturnType["data"]; + clearance: Freight.ClearanceView; + stats: { pct: number; approved: number; total: number }; +}) { + const direction = booking?.tradeDirection ?? "—"; + const origin = + booking?.originYard?.label ?? booking?.originYard?.code ?? "Origin"; + const destination = + booking?.destinationYard?.label ?? + booking?.destinationYard?.code ?? + "Destination"; + + return ( + + + + + + + + + + {booking?.reference ?? "Clearance"} + + + {direction} + + {clearance.includesCustoms ? ( + } + > + Customs + + ) : null} + + + + {origin} + + + + {destination} + + + + + + + + + Document review + + + {stats.approved}/{stats.total} + + + + + + + ); +} + +function ProgressStat({ + color, + label, + value, +}: { + color: string; + label: string; + value: number; +}) { + return ( + + + {value} + + + + + {label} + + + + ); +} + +const STATUS_META: Record< + Freight.DocumentReviewStatus, + { label: string; color: string } +> = { + APPROVED: { label: "Approved", color: "edr-green" }, + QUERIED: { label: "Queried", color: "red" }, + PENDING: { label: "Pending", color: "edr-slate" }, +}; + +function DocReviewCard({ + doc, + note, + queryOpen, + onToggleQuery, + onNote, + onApprove, + onQuery, + busy, +}: { + doc: Freight.ClearanceDocument; + note: string; + queryOpen: boolean; + onToggleQuery: (open: boolean) => void; + onNote: (v: string) => void; + onApprove: () => void; + onQuery: () => void; + busy: boolean; +}) { + const status = doc.reviewStatus ?? "PENDING"; + const meta = STATUS_META[status]; + const hasFile = !!doc.file; + + return ( + + + + + + + + + {doc.label} + {doc.required ? " *" : ""} + + + {hasFile ? doc.file!.name : "Not uploaded by customer"} + + + + + + + {meta.label} + + {hasFile && ( + + + + )} + + + + {status === "QUERIED" && doc.note && ( + } + p="xs" + > + + {doc.note} + + + )} + + {hasFile && ( + + {!queryOpen ? ( + + + + + ) : ( + + + + + Describe the problem for the customer + + +