From d31b4c668a4a4ca736a2ce19c56f2cbc01c29950 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 25 Jun 2026 06:09:21 +0000 Subject: [PATCH] feat: update booking form to remove per-route quantity input for general contracts --- .../entities/booking-order-line.entity.ts | 30 ++++++- .../src/pages/bookings/NewBookingPage.tsx | 25 ++---- .../pages/bookings/new-booking-form/schema.ts | 36 +++----- .../bookings/new-booking-form/step4-route.tsx | 84 ++----------------- .../new-booking-form/step8-review.tsx | 10 +-- .../pages/contracts/ContractDetailPage.tsx | 17 ++-- 6 files changed, 65 insertions(+), 137 deletions(-) diff --git a/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts b/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts index 7b4728e02..d6fff951a 100644 --- a/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts +++ b/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts @@ -3,6 +3,16 @@ import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm'; import { ContainerType } from '../../rule-engine/entities/container-type.entity'; import { BookingOrder } from './booking-order.entity'; +/** + * Postgres `numeric` columns are serialized to JS strings by the driver. This + * transformer hydrates them back into real numbers so consumers (and the + * `quantity: number` API type) don't have to coerce on every read. + */ +const numericColumn = { + to: (value: number) => value, + from: (value: string | null) => (value == null ? value : Number(value)), +}; + /** * One drawn-down quantity line of an order. For CONTAINER contracts there is one * line per container type (matching the contract's pools); for BULK/BREAK_BULK a @@ -25,7 +35,7 @@ export class BookingOrderLine extends BaseEntity { containerType?: ContainerType | null; /** Containers (count), tons, or items depending on the contract's freight/UoM. */ - @Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3 }) + @Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3, transformer: numericColumn }) quantity!: number; /** @@ -33,9 +43,23 @@ export class BookingOrderLine extends BaseEntity { * customer when they toggle the flag. Drives the HAZARD_SURCHARGE / * REEFER_SURCHARGE rates on the spawned child booking. Both ≤ quantity. */ - @Column({ name: 'hazardous_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 }) + @Column({ + name: 'hazardous_quantity', + type: 'numeric', + precision: 12, + scale: 3, + default: 0, + transformer: numericColumn, + }) hazardousQuantity!: number; - @Column({ name: 'reefer_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 }) + @Column({ + name: 'reefer_quantity', + type: 'numeric', + precision: 12, + scale: 3, + default: 0, + transformer: numericColumn, + }) reeferQuantity!: number; } 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 3e1367ba5..7b23d6b16 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -394,13 +394,10 @@ export default function NewBookingPage() { 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; + // Both one-time and general contracts take the bulk amount from the cargo + // step (cargoWeight) — general contracts no longer collect a per-route + // quantity. Item counts are rounded since fractional items are meaningless. + const bulkAmountRaw = data.cargoWeight; const totalWeight = data.cargoType === "container" ? 0 @@ -493,7 +490,9 @@ export default function NewBookingPage() { : { 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. + // carrying the full contracted quantity (from the cargo step). Extra routes + // are just additional origin/destination pairs the contract covers — no + // per-route quantity is collected, so they are sent with quantity 0. ...(isContract ? { routes: [ @@ -509,17 +508,11 @@ export default function NewBookingPage() { : totalWeight, }, ...(data.extraRoutes ?? []) - .filter( - (r) => - r.originYard && - r.destinationYard && - Number(r.quantity) > 0, - ) + .filter((r) => r.originYard && r.destinationYard) .map((r) => ({ originYardId: r.originYard, destinationYardId: r.destinationYard, - quantity: Number(r.quantity), - ...(r.km && Number(r.km) > 0 ? { km: Number(r.km) } : {}), + quantity: 0, })), ], } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts index 969c109cc..84b8172c6 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts @@ -140,22 +140,20 @@ export const bookingFormSchema = z 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). + // Retained for payload/back-compat only — no longer collected in the UI. + // The contracted quantity now comes from the cargo step (cargoWeight), the + // same as a one-time booking, so per-route quantity is no longer entered. 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. + // above is route #1). Each route is just an (origin, destination) pair — + // identical to the one-time route — so a contract can cover several routes. + // Ignored for one-time bookings. quantity/km kept for payload back-compat. extraRoutes: z .array( z.object({ originYard: z.string(), destinationYard: z.string(), - quantity: z.string(), - // Road distance for this route; used to bill road (truck) orders. + quantity: z.string().default(""), km: z.string().default(""), }), ) @@ -221,11 +219,10 @@ 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. + // Both one-time and general contracts capture the bulk amount in the cargo + // step (cargoWeight). General contracts no longer collect a per-route + // quantity, so the cargo amount is the single source for the contract total. 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; }, @@ -257,19 +254,6 @@ 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) { diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx index 7386f2d48..283315351 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx @@ -4,7 +4,6 @@ import { Button, Divider, Group, - NumberInput, Skeleton, Stack, Switch, @@ -137,25 +136,11 @@ export function Step4Route({ 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. + // A general contract can cover several routes, but each route is just an + // (origin, destination) pair — the same shape as the one-time route. The + // contracted quantity comes from the cargo step, so no per-route quantity or + // distance is collected 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; // The reefer toggle only exists for bulk; if the customer switches to // containers, drop any reefer flag they set so it can't ride along unseen. @@ -246,27 +231,6 @@ export function Step4Route({ /> )} - {showRouteQuantity && ( - - ( - field.onChange(String(v ?? ""))} - radius="md" - /> - )} - /> - - )} )} @@ -294,9 +258,9 @@ export function Step4Route({ - A general contract can reserve quantity across several routes. The - route above is your primary route; add more routes and set the - quantity reserved for each. + A general contract can cover several routes. The route above is your + primary route; add more origin–destination routes the contract should + cover. {extraRoutes.map((rf, i) => ( @@ -338,40 +302,6 @@ export function Step4Route({ )} /> - - ( - field.onChange(String(v ?? ""))} - radius="md" - /> - )} - /> - - - ( - field.onChange(String(v ?? ""))} - radius="md" - /> - )} - /> -