From 15ab9f906e4ee1cf3c33c2f6371ed6a0b0da2e03 Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 24 Jun 2026 23:37:55 +0000 Subject: [PATCH] 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} + + + ); + })} + + + )}