From 3e94aa08f15b0b9da95a194a4ef4b1653c6d5f04 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 4 Jul 2026 04:22:03 +0000 Subject: [PATCH 1/2] update joins in repository services to use entity classes and enhance booking form with hazardous/reefer toggles --- .../new-booking-form/step5-cargo-details.tsx | 142 ++++++++++++------ 1 file changed, 98 insertions(+), 44 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx index a02138373..ac336c47c 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx @@ -26,6 +26,70 @@ type BookingForm = UseFormReturn< BookingFormValues >; +/** + * One numbered toggle per container unit in the line — tap units to mark how + * many are hazardous/refrigerated (2 hazardous → toggle 2 units on). Selection + * fills from unit 1: tapping unit N selects 1..N, tapping a selected unit N + * keeps 1..N-1 — the count is always derived, never free-typed, so it can't + * exceed the line quantity. + */ +function UnitCountToggles({ + total, + value, + onChange, + label, + activeBg, + activeBorder, + activeColor, +}: { + total: number; + value: string; + onChange: (v: string) => void; + label: string; + activeBg: string; + activeBorder: string; + activeColor: string; +}) { + const count = Math.min(total, Math.max(0, Math.floor(Number(value) || 0))); + + return ( +
+ + {label} · {count}/{total} selected + +
+ {Array.from({ length: total }, (_, i) => { + const selected = i < count; + return ( + + ); + })} +
+
+ ); +} + export function Step5CargoDetails({ form, referenceData, @@ -144,16 +208,6 @@ export function Step5CargoDetails({ const lineQtyOf = (index: number) => Math.max(1, Number(form.getValues(`containers.${index}.qty`) ?? 1) || 1); const lineMax = (index: number) => lineQtyOf(index); - // When a flag is switched on, default its count to the whole line. - const defaultLineQty = (index: number) => lineQtyOf(index).toString(); - // Clamp a typed value into 1..lineQty (empty stays empty so the field can be - // cleared; the schema flags an empty value as required while the switch is on). - const clampToLine = (raw: string, index: number) => { - if (raw === "") return ""; - const n = Number(raw); - if (Number.isNaN(n)) return raw; - return Math.min(lineQtyOf(index), Math.max(1, Math.floor(n))).toString(); - }; // After the line quantity changes, pull any active count back within bounds. const clampDependentQty = (index: number, newLineQty: number) => { const max = Math.max(1, newLineQty); @@ -636,7 +690,7 @@ export function Step5CargoDetails({ hazField.onChange(v); form.setValue( `containers.${index}.hazardousQty`, - v ? defaultLineQty(index) : "0", + v ? "1" : "0", { shouldDirty: true, shouldValidate: true }, ); }} @@ -645,22 +699,22 @@ export function Step5CargoDetails({ name={`containers.${index}.hazardousQty`} control={form.control} render={({ field: hq, fieldState }) => ( - - hq.onChange( - clampToLine(e.currentTarget.value, index), - ) - } - onBlur={hq.onBlur} - error={fieldState.error?.message} - radius="md" - /> +
+ + {fieldState.error?.message ? ( + + {fieldState.error.message} + + ) : null} +
)} /> @@ -681,7 +735,7 @@ export function Step5CargoDetails({ reeField.onChange(v); form.setValue( `containers.${index}.reeferQty`, - v ? defaultLineQty(index) : "0", + v ? "1" : "0", { shouldDirty: true, shouldValidate: true }, ); }} @@ -690,22 +744,22 @@ export function Step5CargoDetails({ name={`containers.${index}.reeferQty`} control={form.control} render={({ field: rq, fieldState }) => ( - - rq.onChange( - clampToLine(e.currentTarget.value, index), - ) - } - onBlur={rq.onBlur} - error={fieldState.error?.message} - radius="md" - /> +
+ + {fieldState.error?.message ? ( + + {fieldState.error.message} + + ) : null} +
)} /> From 2c0e115c14f03a906b383826838ab20cfe2c1a84 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 4 Jul 2026 04:23:10 +0000 Subject: [PATCH 2/2] update joins in repository services to use entity classes and enhance booking form with hazardous/reefer toggles --- .../modules/bookings/bookings.repository.ts | 7 +- .../contracts/contract-booking.service.ts | 34 +++++ .../contracts/GlCreateBookingForm.tsx | 121 ++++++++++++------ 3 files changed, 120 insertions(+), 42 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 4803f988c..580150eb0 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -5,6 +5,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; +import { Contract } from '../contracts/entities/contract.entity'; import { ContractRoute } from '../contracts/entities/contract-route.entity'; import { BookingApprovalStep } from './entities/booking-approval-step.entity'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; @@ -588,8 +589,10 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('booking.approvalSteps', 'approvalSteps') .leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner') // Contract reference for the list column + search (no entity relation on - // Booking → contract, so join by id and select just the reference). - .leftJoin('freight.contracts', 'contract', 'contract.id = booking.contract_id') + // Booking → contract, so join the entity by id and select just the + // reference — a schema-qualified table string is parsed as alias.relation + // by TypeORM and crashes). + .leftJoin(Contract, 'contract', 'contract.id = booking.contract_id') .addSelect('contract.reference', 'contract_reference') .where('booking.deleted_at IS NULL'); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index c1eb4b4f8..125cf5f3d 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -138,6 +138,11 @@ export class ContractBookingService { // no override. Checked before any row is written. if (freightType === 'CONTAINER') { await this.assertWithinMaxCapacity(contract, dto); + // 20ft weight-pairing gate at CREATION: two 20ft on a wagon must differ + // ≤ the cap, and drawdown bookings never pass through submit — so this is + // their only chance to hard-block an unbalanceable set. Entry order is + // irrelevant (the check sorts by weight before pairing). + await this.assert20ftPairableAtCreate(dto); } // Denormalize route/direction/freight onto the booking for the scheduling engine. @@ -761,6 +766,35 @@ export class ContractBookingService { } } + /** + * Hard-block booking creation when the 20ft container weights cannot be + * balanced onto wagons (pair diff over the global cap). Same rule the + * shipment-form preview reports as `pairingErrors`, enforced server-side. + */ + private async assert20ftPairableAtCreate( + dto: CreateBookingUnderContractDto, + ): Promise { + const twentyFtUnits = (dto.containers ?? []) + .filter((line) => (line.containerSize ?? '').includes('20')) + .flatMap((line, lineIdx) => + (line.units ?? []).map((u, idx) => ({ + label: u.containerNumber || `20ft-${lineIdx + 1}.${idx + 1}`, + grossWeightTons: Number(u.vgmTons ?? 0), + })), + ); + if (twentyFtUnits.length < 2) return; + + const maxDiff = await this.max20ftPairDiffTons(); + const violations = validate20ftWeightPairing(twentyFtUnits, maxDiff); + if (violations.length) { + throw new BadRequestException( + `Cannot create booking — 20ft containers cannot be paired on wagons: ${violations + .map((v) => v.message) + .join(' ')}`, + ); + } + } + private async max20ftPairDiffTons(): Promise { const row = await this.dataSource .getRepository(TrainSchedulingGlobalRules) diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index d967ffc3f..6f52d287f 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -7,6 +7,7 @@ import { import { useMutation, useQuery } from "@tanstack/react-query"; import { Alert, + Badge, Box, Button, Center, @@ -18,6 +19,7 @@ import { Paper, Select, Stack, + Switch, Text, Textarea, TextInput, @@ -82,12 +84,13 @@ interface UnitDraft { containerNumber: string; sealNumber: string; vgmTons: number | string; + /** Per-unit flags — the line's hazardous/reefer counts are derived from these. */ + hazardous: boolean; + reefer: boolean; } interface ContainerLineDraft { containerSize: string; - hazardousQuantity: number | string; - reeferQuantity: number | string; units: UnitDraft[]; } @@ -100,7 +103,13 @@ interface BulkLineDraft { } function emptyUnit(): UnitDraft { - return { containerNumber: "", sealNumber: "", vgmTons: "" }; + return { + containerNumber: "", + sealNumber: "", + vgmTons: "", + hazardous: false, + reefer: false, + }; } function bulkUnitOfMeasure( @@ -199,11 +208,13 @@ export default function GlCreateBookingForm() { setContainerLines( lines.containers.map((c) => ({ containerSize: c.containerSize, - hazardousQuantity: c.hazardousQuantity ?? "0", - reeferQuantity: c.reeferQuantity ?? "", - units: Array.from({ length: Math.max(1, c.quantity) }, () => - emptyUnit(), - ), + // The request carries counts; pre-toggle the first N units so GL sees + // the customer's declared hazardous/reefer split and can adjust it. + units: Array.from({ length: Math.max(1, c.quantity) }, (_, i) => ({ + ...emptyUnit(), + hazardous: i < Number(c.hazardousQuantity ?? 0), + reefer: i < Number(c.reeferQuantity ?? 0), + })), })), ); } else if (lines.bulk) { @@ -229,8 +240,6 @@ export default function GlCreateBookingForm() { setContainerLines( containerSizes.map((size) => ({ containerSize: size, - hazardousQuantity: "0", - reeferQuantity: "0", units: [emptyUnit()], })), ); @@ -261,8 +270,8 @@ export default function GlCreateBookingForm() { containers: containerLines.map((l) => ({ containerSize: l.containerSize, quantity: l.units.length, - hazardousQuantity: Number(l.hazardousQuantity || 0), - reeferQuantity: Number(l.reeferQuantity || 0), + hazardousQuantity: l.units.filter((u) => u.hazardous).length, + reeferQuantity: l.units.filter((u) => u.reefer).length, })), bulkQuantity: bulkLines.reduce( (s, l) => s + Number(l.cargoWeightTons || l.itemCount || 0), @@ -384,12 +393,10 @@ export default function GlCreateBookingForm() { .map((l) => ({ containerSize: l.containerSize, quantity: l.units.length, - ...(l.hazardousQuantity !== "" - ? { hazardousQuantity: Number(l.hazardousQuantity) } - : {}), - ...(l.reeferQuantity !== "" - ? { reeferQuantity: Number(l.reeferQuantity) } - : {}), + // Counts are derived from the per-unit toggles — they can never + // exceed the line quantity. + hazardousQuantity: l.units.filter((u) => u.hazardous).length, + reeferQuantity: l.units.filter((u) => u.reefer).length, units: l.units.map((u) => ({ containerNumber: u.containerNumber, ...(u.sealNumber ? { sealNumber: u.sealNumber } : {}), @@ -652,7 +659,7 @@ export default function GlCreateBookingForm() { {line.containerSize} containers - + syncUnits(lineIdx, Number(v) || 0)} radius={10} styles={fieldStyles} + w={160} /> {contract.isHazardous ? ( - - patchLine(lineIdx, { hazardousQuantity: v }) - } - radius={10} - styles={fieldStyles} - /> + + {line.units.filter((u) => u.hazardous).length} hazardous + ) : null} {contract.isReefer ? ( - - patchLine(lineIdx, { reeferQuantity: v }) - } - radius={10} - styles={fieldStyles} - /> + + {line.units.filter((u) => u.reefer).length} refrigerated + ) : null} Per-container details {line.units.map((unit, unitIdx) => ( - + + {/* Per-unit flags: toggle exactly the containers that are + hazardous / refrigerated; line counts derive from these. */} + {contract.isHazardous ? ( + + {unitIdx === 0 ? ( + + Hazardous + + ) : null} + + patchUnit(lineIdx, unitIdx, { + hazardous: e.currentTarget.checked, + }) + } + /> + + ) : null} + {contract.isReefer ? ( + + {unitIdx === 0 ? ( + + Reefer + + ) : null} + + patchUnit(lineIdx, unitIdx, { + reefer: e.currentTarget.checked, + }) + } + /> + + ) : null} ))}