From 930e39c4fc2346b98e92d6f7a8c775fea87b1645 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 2 Aug 2026 18:39:17 +0000 Subject: [PATCH 1/2] Enhance internal payment handling and e2e testing setup --- .../payment/internal-payment.controller.ts | 5 + docker-compose.e2e.yaml | 2 + e2e/freight/cypress.config.ts | 2 + .../cypress/e2e/flows/export_one_time.cy.ts | 5 + e2e/freight/cypress/e2e/flows/g1-utils.ts | 49 +++++++- .../g1_s3_underfill_day_stays_open.cy.ts | 16 ++- e2e/freight/cypress/e2e/flows/import-utils.ts | 110 +++++++++++++++++- .../cypress/e2e/flows/segment_weight.cy.ts | 5 + .../cypress/fixtures/seed-import-corridor.sql | 18 ++- e2e/freight/scripts/e2e.mjs | 14 ++- 10 files changed, 210 insertions(+), 16 deletions(-) diff --git a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts index b5ff51c48..cd2816fab 100644 --- a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts @@ -16,6 +16,7 @@ import { BillQueryRequestDto, BillQueryResponseDto, } from "./internal-payment.dto"; +import { Public } from "@edr/api-common"; import { PaymentService } from "./payment.service"; import { BillingService } from "../billing/billing.service"; import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; @@ -28,6 +29,10 @@ import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; * this HTTP endpoint remains as a transport-agnostic fallback. */ @ApiTags("Internal Payments") +// Service-to-service, not user-to-service: exempt from the global JwtGuard +// (there is no end-user JWT on a relay call) and authenticated instead by the +// shared service token that ServiceAuthGuard checks. +@Public() @UseGuards(ServiceAuthGuard) @Controller("internal/payments") export class InternalPaymentController { diff --git a/docker-compose.e2e.yaml b/docker-compose.e2e.yaml index 20578fe56..4df2cc1e4 100644 --- a/docker-compose.e2e.yaml +++ b/docker-compose.e2e.yaml @@ -324,6 +324,8 @@ services: CYPRESS_BASE_URL: http://localhost:${E2E_BACKOFFICE_PORT:-5383} CYPRESS_API_URL: http://localhost:${E2E_API_PORT:-3101} CYPRESS_PORTAL_URL: http://localhost:${E2E_PORTAL_PORT:-5373} + # Must match freight-api-e2e's SERVICE_AUTH_TOKEN above. + CYPRESS_SERVICE_AUTH_TOKEN: e2e-service-token volumes: - .:/repo diff --git a/e2e/freight/cypress.config.ts b/e2e/freight/cypress.config.ts index 5abf08477..27451c049 100644 --- a/e2e/freight/cypress.config.ts +++ b/e2e/freight/cypress.config.ts @@ -42,6 +42,8 @@ export default defineConfig({ defaultPassword: process.env.CYPRESS_DEFAULT_PASSWORD ?? "password@tria", // Demo portal users: hardcoded in DemoUsersSeeder. demoPassword: "12345678", + // Shared secret for /api/internal/* — SERVICE_AUTH_TOKEN in docker-compose.e2e.yaml. + serviceAuthToken: process.env.CYPRESS_SERVICE_AUTH_TOKEN ?? "e2e-service-token", }, setupNodeEvents(on) { const dbUrl = diff --git a/e2e/freight/cypress/e2e/flows/export_one_time.cy.ts b/e2e/freight/cypress/e2e/flows/export_one_time.cy.ts index eaba7c298..7435594e2 100644 --- a/e2e/freight/cypress/e2e/flows/export_one_time.cy.ts +++ b/e2e/freight/cypress/e2e/flows/export_one_time.cy.ts @@ -450,6 +450,11 @@ describe("export one-time journeys: container + bulk on one train", { retries: 0 cy.task("db:query", { sql: `UPDATE freight.train_schedules SET window_opens_at = LEAST(window_opens_at, now()), + -- The e2e rules run a 1.002-minute window duration, so the + -- CREATE-time close for a departing-today schedule is already + -- in the past — hold the close out or the next 10s tick slams + -- the window shut mid-flow. + window_closes_at = GREATEST(window_closes_at, now() + interval '45 minutes'), window_phase = 'OPEN', booking_window_status = 'OPEN' WHERE id = $1 AND booking_window_status <> 'FULL'`, diff --git a/e2e/freight/cypress/e2e/flows/g1-utils.ts b/e2e/freight/cypress/e2e/flows/g1-utils.ts index c2631431d..4edc5804d 100644 --- a/e2e/freight/cypress/e2e/flows/g1-utils.ts +++ b/e2e/freight/cypress/e2e/flows/g1-utils.ts @@ -37,6 +37,7 @@ import { db, dbSchedule, forceWindowOpen, + holdPayWindows, opsStaff, ORIGIN, pollDb, @@ -190,8 +191,35 @@ export function closeWindowAndRunBatch(departure: Date) { cy.loginBackoffice(opsStaff); withSchedule(departure, (s) => cy.visit(`/dashboard/operations/batch-board/${s.id}`)); - cy.contains("Doc review", { timeout: 120000 }).should("exist"); - cy.contains("button", "Doc review complete — run batch", { timeout: 120000 }).click(); + // The e2e rules run a 1-MINUTE doc review, and the login + visit above can + // outlive it — the tick then runs the batch itself and the button never + // renders. Click the button while the phase is still DOC_REVIEW; once the + // engine has advanced on its own there is nothing left to click, and the + // poll below asserts the batch ran either way. + withSchedule(departure, (s) => { + const tryRunBatch = (attempt: number): void => { + db<{ p: string }>( + `SELECT window_phase AS p FROM freight.train_schedules WHERE id = $1`, + [s.id], + ).then(({ rows }) => { + if (rows[0].p !== "DOC_REVIEW") return; // tick already ran the batch + cy.get("body").then(($body) => { + const button = $body.find( + 'button:contains("Doc review complete — run batch")', + ); + if (button.length > 0) { + cy.wrap(button.first()).click({ force: true }); + return; + } + expect(attempt, "batch board rendered its doc-review action").to.be.lessThan( + 20, + ); + cy.wait(3000, { log: false }).then(() => tryRunBatch(attempt + 1)); + }); + }); + }; + tryRunBatch(0); + }); withSchedule(departure, (s) => pollDb( @@ -199,10 +227,16 @@ export function closeWindowAndRunBatch(departure: Date) { `SELECT window_phase FROM freight.train_schedules WHERE id = $1`, [s.id], // DONE when the batch reserved nobody — itself a scenario outcome. - (row) => !!row && ["PAYMENT", "DONE"].includes(row.window_phase as string), + // PRE_WINDOW/OPEN when an under-filled day concluded and re-opened for + // its next cycle (window duration is 1 minute in e2e). + (row) => + !!row && + ["PAYMENT", "DONE", "PRE_WINDOW", "OPEN"].includes(row.window_phase as string), 20, ), ); + // The batch stamped 1-minute pay windows; hold them while the spec pays. + holdPayWindows(); } /** @@ -224,6 +258,15 @@ export function expectBoard( cy.loginBackoffice(opsStaff); withSchedule(departure, (s) => cy.visit(`/dashboard/operations/batch-board/${s.id}`)); cy.contains(/Priority Tracking/, { timeout: 120000 }).click(); + // While a window is OPEN the tab defaults to the Forecast view (an + // under-filled day re-opens for its next cycle — g1_s3's core scenario) and + // the live lanes are hidden behind the "Live state" toggle. On settled + // boards the toggle is not rendered at all, so only click it when present. + cy.contains(/Priority ranking|Live state/, { timeout: 120000 }) + .invoke("text") + .then((text) => { + if (text.includes("Live state")) cy.contains("Live state").click(); + }); cy.contains("Priority ranking", { timeout: 120000 }).should("be.visible"); if (opts.inBatch !== undefined) { diff --git a/e2e/freight/cypress/e2e/flows/g1_s3_underfill_day_stays_open.cy.ts b/e2e/freight/cypress/e2e/flows/g1_s3_underfill_day_stays_open.cy.ts index 0f925eb14..75ae1a6b3 100644 --- a/e2e/freight/cypress/e2e/flows/g1_s3_underfill_day_stays_open.cy.ts +++ b/e2e/freight/cypress/e2e/flows/g1_s3_underfill_day_stays_open.cy.ts @@ -91,7 +91,17 @@ describe("G1·S3: an under-filled train keeps its day open", { retries: 0 }, () configureAndOpenSchedule({ departure: DEPARTURE }); }); - it("A and B book through the API; C books 24×20FT through the portal", () => { + // The API bookings and the portal booking are SEPARATE tests on purpose. + // cy.loginPortal's cross-origin visit makes Cypress reload the runner, + // re-evaluate the bundle (re-running `before()` and regenerating the + // module-scope stamp) and restart the CURRENT test from the top. With A and + // B in the same test as the portal visit they were booked twice — once per + // pass, under two stamps, even on a freshly wiped DB — and the orphaned + // first pair expired at payment end, corrupting the board counts and the + // free-wagon arithmetic. In their own test the completed API step is never + // re-entered; the restart only repeats the login. Same structure as g1_s2, + // which is why that spec never double-booked. + it("A and B book through the API", () => { let isoSeed = 8600; (["A", "B"] as const).forEach((suffix) => { const shape = SHAPES[suffix]; @@ -105,8 +115,12 @@ describe("G1·S3: an under-filled train keeps its day open", { retries: 0 }, () }); isoSeed += shape.twenty + shape.forty; }); + }); + it("C books 24×20FT through the portal shipment form", () => { cy.loginPortal(customer); + // dbContractId picks the NEWEST *-C contract, so the duplicate seeded by + // the reload's before() pass is inert. dbContractId("C").then((contractId) => { bookContainersVisually({ contractId, diff --git a/e2e/freight/cypress/e2e/flows/import-utils.ts b/e2e/freight/cypress/e2e/flows/import-utils.ts index 5337d9f9c..94b871412 100644 --- a/e2e/freight/cypress/e2e/flows/import-utils.ts +++ b/e2e/freight/cypress/e2e/flows/import-utils.ts @@ -640,8 +640,47 @@ export function expectClearanceOnBookingInvoice(suffix: string) { ); } +/** + * Push every still-unpaid fixture reservation's pay deadline out 10 minutes. + * + * The e2e rules run a 1-MINUTE payment window (seed-import-corridor.sql), and + * a spec's pay loop — login, settle, poll, per booking — always outlives it: + * without this the 10s tick expires the holds the spec is queued up to pay. + * Called right after the batch reserves (completeDocReview, + * closeWindowAndRunBatch), after an export FCFS accept, and again before each + * payment. Blanket over CTR-IMP-% on purpose: specs run one at a time, and the + * first payment must rescue its yet-unpaid siblings, whichever schedule they + * reserved onto. + * + * Two invariants preserved: + * - export parity ("the pay window never outlives the window close"): while a + * booking's window is still open, the extension clamps to window_closes_at; + * - expiry scenarios: specs that TEST expiry pull deadlines back into the + * past afterwards (forceReservationExpiry / forceOfferLapse), and + * endPaymentPhase now expires its schedule's unpaid holds itself — so the + * extension never masks an expiry. + */ +export function holdPayWindows() { + db( + `UPDATE freight.bookings b + SET payment_deadline = LEAST( + now() + interval '10 minutes', + COALESCE( + (SELECT ts.window_closes_at FROM freight.train_schedules ts + WHERE ts.id = b.train_schedule_id + AND ts.window_closes_at > now()), + now() + interval '10 minutes')) + FROM freight.contracts ct + WHERE ct.id = b.contract_id AND ct.reference LIKE 'CTR-IMP-%' + AND b.deleted_at IS NULL + AND b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT') + AND b.payment_deadline IS NOT NULL`, + ); +} + /** Staff force-pay; polls PAID + SCHEDULED. */ export function markPaid(suffix: string) { + holdPayWindows(); withBooking(suffix, (b) => { apiPost(opsStaff, `/api/train-scheduling/bookings/${b.id}/mark-paid`) .its("status") @@ -668,6 +707,7 @@ export function markPaid(suffix: string) { * path that applies a pending split offer (staff mark-paid skips it). */ export function settleViaGateway(suffix: string) { + holdPayWindows(); withBooking(suffix, (b) => { db<{ intent_id: string; currency: string; total: string }>( `WITH inv AS ( @@ -698,6 +738,9 @@ export function settleViaGateway(suffix: string) { cy.request({ method: "POST", url: `${apiUrl()}/api/internal/payments/mark-paid`, + headers: { + "x-service-token": Cypress.env("serviceAuthToken") as string, + }, body: { version: 1, eventId: crypto.randomUUID(), @@ -917,6 +960,25 @@ export function resetCorridorDay(departure: Date, destCode = DEST, originCode = AND b.scheduled_date = $1::date`, [eatDayStr(departure)], ); + // Finally, soft-delete every remaining unpinned fixture booking on the day. + // Reset runs before the current run books anything, so all of them are + // prior-run debris — and merely leaving them unpinned is not enough: + // - EXPIRED ones render in the board's "Expired" lane (it lists by DAY), + // so `expired: 0` could never pass against a warm DB; + // - PAID ones sit in the day pool, and when an under-filled day re-opens + // for its next cycle the engine's batch fill re-links them to the LIVE + // schedule mid-run — observed as 15 ghosts re-pinned within one second, + // inflating "In the batch (N)" past what the spec created. + db( + `UPDATE freight.bookings b + SET deleted_at = now() + FROM freight.contracts ct + WHERE ct.id = b.contract_id AND ct.reference LIKE 'CTR-IMP-%' + AND b.deleted_at IS NULL + AND b.train_schedule_id IS NULL + AND b.scheduled_date = $1::date`, + [eatDayStr(departure)], + ); } /** Create the reversed 6-stop corridor (ET → DJ = EXPORT) if missing. */ @@ -959,6 +1021,10 @@ export function acceptExport(suffix: string) { .should("be.oneOf", [200, 201]); }); pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"], 10); + // The accept stamped a 1-minute pay window (export_payment_window_minutes); + // a spec accepting several bookings would lose the first before the last is + // even accepted. Still clamped to the window close — see holdPayWindows. + holdPayWindows(); } export interface ScheduleRow { @@ -1228,18 +1294,54 @@ export function closeBookingWindow(scheduleId: string) { * (Lands on DONE instead when the batch reserved nobody.) */ export function completeDocReview(scheduleId: string) { - apiPost(opsStaff, `/api/train-scheduling/schedules/${scheduleId}/doc-review-complete`) - .its("status") - .should("be.oneOf", [200, 201]); + apiPost( + opsStaff, + `/api/train-scheduling/schedules/${scheduleId}/doc-review-complete`, + undefined, + false, + ).then((res) => { + if (res.status >= 400) { + // The e2e rules run a 1-MINUTE doc review: the tick may have run the + // batch on its own while the spec was still logging in or asserting. + // That is the engine doing the right thing on schedule — but a 4xx with + // the phase still stuck in DOC_REVIEW is a real failure. + db<{ p: string }>( + `SELECT window_phase AS p FROM freight.train_schedules WHERE id = $1`, + [scheduleId], + ).then(({ rows }) => { + expect( + rows[0]?.p, + `doc-review-complete ${res.status} — engine advanced on its own`, + ).to.be.oneOf(["PAYMENT", "DONE", "PRE_WINDOW", "OPEN"]); + }); + } + }); pollSchedulePhase( scheduleId, - ["PAYMENT", "DONE", "PRE_WINDOW"], + // OPEN: with a 1-minute window duration an under-filled day can already + // have re-opened for its next cycle by the first poll read. + ["PAYMENT", "DONE", "PRE_WINDOW", "OPEN"], `schedule ${scheduleId} payment phase`, ); + holdPayWindows(); } /** End the payment phase now — the tick settles (allocate paid / expire unpaid). */ export function endPaymentPhase(scheduleId: string) { + // holdPayWindows pushed the unpaid holds' own deadlines out so a pay loop + // could outlive the 1-minute window; ending the phase means those holds must + // now expire, so pull them back first — the settle only expires reservations + // whose OWN deadline has passed, and holds the cycle open for the rest. + db( + `UPDATE freight.bookings b + SET payment_deadline = now() - interval '1 second' + FROM freight.contracts ct + WHERE ct.id = b.contract_id AND ct.reference LIKE 'CTR-IMP-%' + AND b.deleted_at IS NULL + AND b.train_schedule_id = $1 + AND b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`, + [scheduleId], + ); db( `UPDATE freight.train_schedules SET payment_phase_ends_at = now() - interval '1 second' diff --git a/e2e/freight/cypress/e2e/flows/segment_weight.cy.ts b/e2e/freight/cypress/e2e/flows/segment_weight.cy.ts index 16ef643dc..28068f548 100644 --- a/e2e/freight/cypress/e2e/flows/segment_weight.cy.ts +++ b/e2e/freight/cypress/e2e/flows/segment_weight.cy.ts @@ -428,6 +428,11 @@ describe( cy.task("db:query", { sql: `UPDATE freight.train_schedules SET window_opens_at = LEAST(window_opens_at, now()), + -- The e2e rules run a 1.002-minute window duration, so the + -- CREATE-time close for a departing-today schedule is already + -- in the past — hold the close out or the next 10s tick slams + -- the window shut mid-flow. + window_closes_at = GREATEST(window_closes_at, now() + interval '45 minutes'), window_phase = 'OPEN', booking_window_status = 'OPEN' WHERE id = $1 AND booking_window_status <> 'FULL'`, diff --git a/e2e/freight/cypress/fixtures/seed-import-corridor.sql b/e2e/freight/cypress/fixtures/seed-import-corridor.sql index 05d45561f..8d7aedb06 100644 --- a/e2e/freight/cypress/fixtures/seed-import-corridor.sql +++ b/e2e/freight/cypress/fixtures/seed-import-corridor.sql @@ -450,14 +450,20 @@ WHERE NOT EXISTS ( ); -- --------------------------------------------------------------------------- --- e2e window durations: 1 minute instead of the 30/60 production defaults. +-- e2e window durations — the dev-environment settings, verbatim: +-- window duration 0.0167 h (1.002 min), doc review 1 min, payment 1 min +-- (import AND export). -- --- Most specs never wait these out — closeWindowAndRunBatch clicks "Doc review --- complete" and endPaymentPhase pulls the deadline into the past — so this is --- a safety net for the paths that DO let a phase elapse on its own, not the --- main speed lever. That one is the 10s @Cron tick in booking-window.service. +-- Specs still arrange the timestamps they need (forceWindowOpen holds a +-- window open for 45 min; endPaymentPhase ends the pay phase early), but +-- every ENGINE-stamped deadline now comes from these 1-minute rules: the +-- batch's pay windows, the doc-review auto-advance, and re-opened cycles all +-- elapse in about a minute on their own via the 10s @Cron tick in +-- booking-window.service. holdPayWindows (import-utils.ts) is what keeps a +-- spec's queued-up payments from expiring under the 1-minute pay window. -- --------------------------------------------------------------------------- UPDATE freight.train_scheduling_global_rules - SET doc_review_minutes = 1, + SET window_duration_hours = 0.0167, + doc_review_minutes = 1, payment_window_minutes = 1, export_payment_window_minutes = 1; diff --git a/e2e/freight/scripts/e2e.mjs b/e2e/freight/scripts/e2e.mjs index ee59f0098..fe8d639b2 100644 --- a/e2e/freight/scripts/e2e.mjs +++ b/e2e/freight/scripts/e2e.mjs @@ -16,7 +16,7 @@ */ import { execFileSync, spawnSync } from "node:child_process"; -import { generateKeyPairSync } from "node:crypto"; +import { createHash, generateKeyPairSync } from "node:crypto"; import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { createServer } from "node:net"; import { dirname, join, resolve } from "node:path"; @@ -25,7 +25,17 @@ import { fileURLToPath } from "node:url"; const e2eDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const repoRoot = resolve(e2eDir, "..", ".."); const stateFile = join(e2eDir, ".e2e-ports.json"); -const composeBase = ["compose", "-f", join(repoRoot, "docker-compose.e2e.yaml")]; +// Per-checkout compose project: parallel checkouts on one docker daemon +// otherwise share the yaml's fixed `name:` and recreate/kill each other's +// containers mid-run. +const projectName = `edr-freight-e2e-${createHash("sha1").update(repoRoot).digest("hex").slice(0, 6)}`; +const composeBase = [ + "compose", + "-p", + projectName, + "-f", + join(repoRoot, "docker-compose.e2e.yaml"), +]; const DEFAULT_PORTS = { E2E_API_PORT: 3101, From 3956c78c54919ed104bf8b0bd8943f531026a78a Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 2 Aug 2026 19:17:17 +0000 Subject: [PATCH 2/2] Enhance internal payment handling and e2e testing setup --- .../contracts/GlCreateBookingForm.tsx | 4 +- .../TrainScheduleV2DetailPage.tsx | 13 +- .../src/pages/contracts/NewShipmentPage.tsx | 338 ++++++++++-------- 3 files changed, 201 insertions(+), 154 deletions(-) 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 27e1597c5..4a1cb402f 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -172,11 +172,11 @@ function emptyUnit(): UnitDraft { function emptyLine(size: string): ContainerLineDraft { return { containerSize: size, - quantity: "1", + quantity: "0", hazardousQuantity: "0", reeferQuantity: "0", returnQuantity: "0", - units: [emptyUnit()], + units: [], }; } diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index e0c17a57b..e89977eef 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -742,16 +742,7 @@ export default function TrainScheduleV2DetailPage() { {canEditBookings && (previewResult || displayWagonPlan.length) ? ( - {!hasContainerStep ? ( - - ) : ( + {hasContainerStep ? ( - )} + ) : null} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index 50f2828f6..6b2ce4470 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -266,8 +266,7 @@ function NewShipmentBookingForm({ withReturn: contract.equipmentReturn === "WITH_RETURN", // The contract quotes USD; the customer bills this shipment in the // currency they pick here. Intercity is always ETB. - paymentCurrency: - contract.tradeDirection === "DOMESTIC" ? "ETB" : "USD", + paymentCurrency: contract.tradeDirection === "DOMESTIC" ? "ETB" : "USD", }, resolver: zodResolver( createShipmentFormSchema({ @@ -306,7 +305,10 @@ function NewShipmentBookingForm({ bookingId: completeBookingId, dto, }) - : api.contracts.createBookingUnderContract.call({ id: contractId, dto }), + : api.contracts.createBookingUnderContract.call({ + id: contractId, + dto, + }), onSuccess: (booking) => { queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); queryClient.invalidateQueries({ @@ -366,7 +368,8 @@ function NewShipmentBookingForm({ .map((l) => ({ containerSize: l.containerSize, quantity: Number(l.quantity), - hazardousQuantity: Number(l.hazardousQuantity || 0) || undefined, + hazardousQuantity: + Number(l.hazardousQuantity || 0) || undefined, reeferQuantity: Number(l.reeferQuantity || 0) || undefined, ...(withReturnService ? { returnQuantity: Number(l.returnQuantity || 0) } @@ -379,7 +382,9 @@ function NewShipmentBookingForm({ // line counts and bills each surcharge on the ticked containers. isHazardous: Boolean(u.isHazardous), isReefer: Boolean(u.isReefer), - ...(withReturnService ? { isReturn: Boolean(u.isReturn) } : {}), + ...(withReturnService + ? { isReturn: Boolean(u.isReturn) } + : {}), })), })), } @@ -417,6 +422,12 @@ function NewShipmentBookingForm({ validateMutation.mutate(buildDto(values)); }); + // The per-field messages render inline, but on a long single-page form the + // failing field is often scrolled out of view — mirror the backoffice's + // summary alert next to the submit button so the click never looks inert. + const showValidationSummary = + form.formState.isSubmitted && !form.formState.isValid; + const handleConfirm = () => { if (!pendingValues) return; // Guard: never let a booking with unresolved 20ft pairing errors submit. @@ -457,8 +468,15 @@ function NewShipmentBookingForm({ mb="lg" > - - {completeBookingId ? "Complete Your Booking" : "New Shipment Booking"} + <Title + order={1} + fw={800} + fz={26} + style={{ letterSpacing: "-0.01em" }} + > + {completeBookingId + ? "Complete Your Booking" + : "New Shipment Booking"} {completeBookingId @@ -534,28 +552,41 @@ function NewShipmentBookingForm({ marginTop: "auto", }} > - - - {/* Mantine tooltips get no pointer events from a disabled button, + + {showValidationSummary ? ( + } + mb="sm" + > + Fix the highlighted fields before reviewing the price. + + ) : null} + + + {/* Mantine tooltips get no pointer events from a disabled button, so the wrapper carries the hover target. */} - - - - - + + + + + + @@ -621,8 +652,7 @@ function PriceConfirmModal({ quantity: li.quantity, amount: li.amount, })), - total: - validation?.totalAmount ?? items.reduce((s, l) => s + l.amount, 0), + total: validation?.totalAmount ?? items.reduce((s, l) => s + l.amount, 0), }; }, [validation, baseTotal]); @@ -715,8 +745,8 @@ function PriceConfirmModal({ ))} - Adjust the 20ft container weights or quantities so pairs differ - by no more than 10 tons. + Adjust the 20ft container weights or quantities so pairs + differ by no more than 10 tons. @@ -810,7 +840,12 @@ function PriceConfirmModal({ )} - + {total.lines.map((line, i) => ( @@ -1007,7 +1042,9 @@ function ScheduleStep({ const isIntercity = contract.tradeDirection === "DOMESTIC"; const { data: availableDays, isLoading } = useQuery({ ...api.bookings.getAvailableDaysForCargo.queryOptions({ - input: cargoQuery ?? ({ freightType: "BULK" } as Freight.AvailableDaysForCargoQuery), + input: + cargoQuery ?? + ({ freightType: "BULK" } as Freight.AvailableDaysForCargoQuery), }), enabled: cargoQuery !== null && !isIntercity, }); @@ -1064,7 +1101,12 @@ function ScheduleStep({ title="Schedule" description="Intercity shipments have no fixed day." /> - }> + } + > Your shipment rides the next import/export train passing through your corridor. Operations assign it to a train with free capacity — you will be notified when it is accepted and payment is due. @@ -1110,7 +1152,12 @@ function ScheduleStep({ )} /> {cargoQuery === null ? ( - }> + } + > Enter your cargo details first — available shipment days depend on the wagons your cargo needs. @@ -1245,11 +1292,11 @@ function CargoStep({ "containers", sizes.map((size) => ({ containerSize: size, - quantity: "1", + quantity: "0", hazardousQuantity: "0", reeferQuantity: "0", returnQuantity: "0", - units: [emptyUnit()], + units: [], })), { shouldValidate: false }, ); @@ -1289,11 +1336,11 @@ function CargoStep({ return ( current.find((l) => l.containerSize === size) ?? { containerSize: size, - quantity: "1", + quantity: "0", hazardousQuantity: "0", reeferQuantity: "0", returnQuantity: "0", - units: [emptyUnit()], + units: [], } ); } @@ -1315,7 +1362,10 @@ function CargoStep({ })), }; }); - form.setValue("containers", next, { shouldValidate: true, shouldDirty: true }); + form.setValue("containers", next, { + shouldValidate: true, + shouldDirty: true, + }); setImportErrors([]); setImportSummary(`Imported ${rows.length} container(s) from ${file.name}.`); }; @@ -1330,7 +1380,12 @@ function CargoStep({ /> {sizes.length > 0 && ( - + @@ -1458,10 +1513,11 @@ function CargoStep({ title={`Odd number of 20ft containers (${ft20})`} > - 20ft containers travel two per wagon, so they must be booked in - even numbers. Please add one more 20ft container or remove one - (e.g. book {ft20 + 1} or {ft20 - 1} instead of {ft20}) — the - booking cannot be submitted with an unpaired 20ft container. + 20ft containers travel two per wagon, so they must be booked + in even numbers. Please add one more 20ft container or remove + one (e.g. book {ft20 + 1} or {ft20 - 1} instead of {ft20}) — + the booking cannot be submitted with an unpaired 20ft + container. ); @@ -1660,16 +1716,6 @@ function NotesSection({ form }: { form: ShipmentForm }) { ); } -/** A blank container row — handling switches start off. */ -const emptyUnit = () => ({ - containerNumber: "", - sealNumber: "", - vgmTons: "", - isHazardous: false, - isReefer: false, - isReturn: false, -}); - function ContainerLineEditor({ form, index, @@ -1726,7 +1772,11 @@ function ContainerLineEditor({ * price estimate and the submitted payload stay in step with the switches. */ const syncHandlingCounts = ( - units: Array<{ isHazardous?: boolean; isReefer?: boolean; isReturn?: boolean }>, + units: Array<{ + isHazardous?: boolean; + isReefer?: boolean; + isReturn?: boolean; + }>, ) => { const set = ( key: "hazardousQuantity" | "reeferQuantity" | "returnQuantity", @@ -1856,94 +1906,100 @@ function ContainerLineEditor({ ))} )} - {Array.from({ length: Math.max(quantity, units.length) }).map((_, u) => ( - - ( - - field.onChange(e.currentTarget.value.toUpperCase()) - } - placeholder="e.g. MSCU1234567" - error={fieldState.error?.message} - radius={10} - styles={fieldStyles} - style={{ flex: 1 }} - /> - )} - /> - ( - - )} - /> - ( - - )} - /> - {handlingColumns.map((col) => ( + {Array.from({ length: Math.max(quantity, units.length) }).map( + (_, u) => ( + ( - - - toggleUnitHandling(u, col.key, e.currentTarget.checked) - } - size="sm" - /> - + render={({ field, fieldState }) => ( + + field.onChange(e.currentTarget.value.toUpperCase()) + } + placeholder="e.g. MSCU1234567" + error={fieldState.error?.message} + radius={10} + styles={fieldStyles} + style={{ flex: 1 }} + /> )} /> - ))} - removeUnit(u)} - > - - - - ))} + ( + + )} + /> + ( + + )} + /> + {handlingColumns.map((col) => ( + ( + + + toggleUnitHandling( + u, + col.key, + e.currentTarget.checked, + ) + } + size="sm" + /> + + )} + /> + ))} + removeUnit(u)} + > + + + + ), + )} );