From 1b8c7e4296eddd360c58a87614df5a0183b1050a Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 6 Aug 2026 21:57:08 +0000 Subject: [PATCH] full change-booking flow for op revisions --- .../components/ClearanceCard.tsx | 15 +- .../bookings/clearance/bookingNextAction.ts | 20 +- .../src/pages/contracts/NewShipmentPage.tsx | 174 +++++++++++++++++- 3 files changed, 197 insertions(+), 12 deletions(-) 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 e5ea8d3a9..3002356f7 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 @@ -51,7 +51,12 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) { } const summary = - status === "CLEARANCE_READY" ? ( + status === "OPERATION_CHANGES_REQUESTED" ? ( + }> + Operations returned this order for changes. Update the booking details, + pick a new shipment day and resubmit. + + ) : status === "CLEARANCE_READY" ? ( }> {`${ booking.customsClearingEnabled @@ -103,9 +108,11 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) { {summary} - {isBookAction - ? "Use “Book” to enter the cargo details and schedule your shipment." - : `Use “${action?.label ?? "the action button"}” to manage your ${docNoun}.`} + {status === "OPERATION_CHANGES_REQUESTED" + ? "Use “Change booking” to update the details and pick a new shipment day." + : isBookAction + ? "Use “Book” to enter the cargo details and schedule your shipment." + : `Use “${action?.label ?? "the action button"}” to manage your ${docNoun}.`} {!isBookAction && ( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts b/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts index ea70e2fa9..297b0c152 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts @@ -90,11 +90,21 @@ function actionByStatus(booking: ActionBooking): BookingNextAction | null { title: "Schedule your shipment", }; case "OPERATION_CHANGES_REQUESTED": - return { - kind: "SCHEDULE_OPERATION", - label: "Choose day & resubmit", - title: "Resubmit your shipment", - }; + // Contract bookings reopen the full completion form (cargo + shipment + // day, prefilled from the booking) — same page as the initial booking. + // Contract-less bookings keep the in-place day-picker modal. + return booking.contractId + ? { + kind: "BOOK", + label: "Change booking", + title: "Change your booking", + to: `/contracts/${booking.contractId}/bookings/${booking.id}/complete`, + } + : { + kind: "SCHEDULE_OPERATION", + label: "Choose day & resubmit", + title: "Resubmit your shipment", + }; default: return 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 aa4ce723a..aab5d7de1 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -38,6 +38,7 @@ import { CheckCircle2, ChevronLeft, FileDown, + FileText, FileUp, Flame, MapPin, @@ -59,6 +60,7 @@ import { contractsService, type ShipmentValidation, } from "@/services/contracts.service"; +import { downloadStoredFile } from "@/services/files.service"; import { SelectField, StepCard, @@ -245,6 +247,127 @@ function bulkUnitOfMeasure( return hasPerItem ? "PER_ITEM" : "PER_TON"; } +/** + * Prefill for a changes-requested resubmit: the booking's persisted cargo, + * currency and route become the form's starting values so the customer edits + * what exists instead of retyping it. The shipment day is deliberately left + * empty — a new day must be picked. + */ +function mapBookingToShipmentValues( + booking: Freight.IBooking, + contract: Freight.IContract, +): Partial { + const b = booking as unknown as { + cargoFreeText?: string | null; + contractRouteId?: string | null; + cargoTotalWeightVgm?: number | string | null; + bulkTotalWeightTons?: number | string | null; + bulkHazardousQuantity?: number | string | null; + bulkReeferQuantity?: number | string | null; + bookingContainers?: Array<{ + quantity?: number; + hazardousQuantity?: number | string | null; + reeferQuantity?: number | string | null; + returnQuantity?: number | string | null; + containerType?: { sizeFt?: number | null } | null; + units?: Array<{ + containerNumber?: string; + sealNumber?: string | null; + vgmTons?: number | string; + isHazardous?: boolean; + isReefer?: boolean; + isReturn?: boolean; + }>; + }>; + }; + const values: Partial = { + paymentCurrency: booking.paymentCurrency === "ETB" ? "ETB" : "USD", + withReturn: booking.equipmentReturn === "WITH_RETURN", + cargoDescription: b.cargoFreeText ?? "", + ...(b.contractRouteId ? { contractRouteId: b.contractRouteId } : {}), + }; + if (contract.freightType === "CONTAINER") { + const rows = b.bookingContainers ?? []; + const lineFor = (size: "20ft" | "40ft") => { + const bc = rows.find( + (r) => (r.containerType?.sizeFt === 40 ? "40ft" : "20ft") === size, + ); + return { + containerSize: size, + quantity: String(bc?.quantity ?? 0), + hazardousQuantity: String(Number(bc?.hazardousQuantity ?? 0)), + reeferQuantity: String(Number(bc?.reeferQuantity ?? 0)), + returnQuantity: String(Number(bc?.returnQuantity ?? 0)), + units: (bc?.units ?? []).map((u) => ({ + containerNumber: u.containerNumber ?? "", + sealNumber: u.sealNumber ?? "", + vgmTons: String(Number(u.vgmTons ?? 0)), + isHazardous: Boolean(u.isHazardous), + isReefer: Boolean(u.isReefer), + isReturn: Boolean(u.isReturn), + })), + }; + }; + const sizes = (contract.cargoScope ?? []) + .map((s) => s.containerSize) + .filter((s): s is "20ft" | "40ft" => s === "20ft" || s === "40ft"); + values.containers = (sizes.length ? sizes : ["20ft", "40ft"]).map(lineFor); + } else { + const perItem = bulkUnitOfMeasure(contract) === "PER_ITEM"; + const amount = Number(b.cargoTotalWeightVgm ?? 0); + if (perItem) { + values.itemCount = amount ? String(amount) : ""; + values.cargoWeightTons = + b.bulkTotalWeightTons != null + ? String(Number(b.bulkTotalWeightTons)) + : ""; + } else { + values.cargoWeightTons = amount ? String(amount) : ""; + } + values.bulkHazardousQuantity = String(Number(b.bulkHazardousQuantity ?? 0)); + values.bulkReeferQuantity = String(Number(b.bulkReeferQuantity ?? 0)); + } + return values; +} + +/** Read-only list of the booking's already-uploaded documents (resubmit view). */ +function UploadedDocumentsCard({ booking }: { booking: Freight.IBooking }) { + const files = + (booking as unknown as { files?: Array<{ id: string; name: string }> }) + .files ?? []; + if (!files.length) return null; + return ( + + + + + Your uploaded documents + + + + These stay attached to the booking — no need to upload them again. + + + {files.map((f) => ( + + + {f.name} + + void downloadStoredFile(f.id, f.name)} + aria-label={`Download ${f.name}`} + > + + + + ))} + + + ); +} + function NewShipmentBookingForm({ contract, contractId, @@ -306,6 +429,34 @@ function NewShipmentBookingForm({ : 0; const hasOdd20ft = ft20Total % 2 === 1; + // COMPLETION mode: fetch the booking — a changes-requested resubmit prefills + // the form from it and shows the operations note + uploaded documents. + const { data: completeBooking } = useQuery( + api.bookings.get.queryOptions({ + input: { id: completeBookingId! }, + enabled: Boolean(completeBookingId), + }), + ); + const isResubmit = Boolean( + completeBooking && + ["OPERATION_CHANGES_REQUESTED", "EXPIRED"].includes( + completeBooking.status as string, + ) && + (((completeBooking as unknown as { bookingContainers?: unknown[] }) + .bookingContainers?.length ?? 0) > 0 || + Number(completeBooking.cargoTotalWeightVgm ?? 0) > 0), + ); + const prefilledRef = useRef(false); + useEffect(() => { + if (!isResubmit || prefilledRef.current || !completeBooking) return; + prefilledRef.current = true; + form.reset({ + ...form.getValues(), + ...mapBookingToShipmentValues(completeBooking, contract), + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isResubmit]); + const submitMutation = useMutation({ mutationFn: (dto: Freight.CreateBookingUnderContractDto) => completeBookingId @@ -488,12 +639,16 @@ function NewShipmentBookingForm({ style={{ letterSpacing: "-0.01em" }} > {completeBookingId - ? "Complete Your Booking" + ? isResubmit + ? "Change Your Booking" + : "Complete Your Booking" : "New Shipment Booking"} {completeBookingId - ? `Clearance is finalized — enter the cargo details and shipment day to complete your booking under contract ${contract.reference}.` + ? isResubmit + ? `Update the details below and pick a new shipment day, then resubmit your booking under contract ${contract.reference}.` + : `Clearance is finalized — enter the cargo details and shipment day to complete your booking under contract ${contract.reference}.` : `Book a shipment against contract ${contract.reference}.`} @@ -535,6 +690,16 @@ function NewShipmentBookingForm({ {/* Single-step form — all sections on one page. */} + {isResubmit && completeBooking?.latestChangeRequestNote && ( + } + radius="md" + title="Operations requested changes" + > + {completeBooking.latestChangeRequestNote} + + )} {/* Legacy contracts only — WITH_RETURN contracts capture per-line @@ -547,6 +712,9 @@ function NewShipmentBookingForm({ routes={routes} completeBookingId={completeBookingId ?? null} /> + {isResubmit && completeBooking && ( + + )} {/* Notes are captured when the booking is initiated — completing a bare booking does not re-ask for them. */} {!completeBookingId && } @@ -594,7 +762,7 @@ function NewShipmentBookingForm({ onClick={handleReview} disabled={hasOdd20ft} > - Review price & book + {isResubmit ? "Change booking" : "Review price & book"}