From 4eb0d56faf24159072f15543f6aae4d1f62c64b6 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 6 Aug 2026 20:25:08 +0000 Subject: [PATCH] feat(bookings): show allocated wagons in portal --- .../modules/bookings/bookings.controller.ts | 16 + .../src/modules/bookings/bookings.service.ts | 57 +++ .../BookingDetailPage/ReadonlyBookingView.tsx | 16 + .../components/WagonsTab.tsx | 445 ++++++++++++++++++ .../portal/src/services/bookings.service.ts | 37 ++ 5 files changed, 571 insertions(+) create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonsTab.tsx diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index e57f94eae..1e932cbc0 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -496,6 +496,22 @@ export class BookingsController { res.send(buffer); } + @Get(':id/wagons') + @ApiOperation({ + summary: + 'Allocated wagons for a booking (JSON) — empty until the paid booking is placed on a train', + }) + async wagonAllocations( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + return this.bookingsService.wagonAllocations(id); + } + @Get(':id/customer-trucks') @ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' }) async listCustomerTrucks( 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 1d2300d9a..0f5b5fbbc 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -339,6 +339,63 @@ export class BookingsService { }; } + /** + * Allocated wagons of a booking as JSON — the portal's "Wagons" tab. Same + * join chain as the carriage acceptance sheet, but structured (containers as + * an array per wagon, bulk load description when the wagon carries bulk). + * Empty array until the booking has been allocated onto a train. + */ + async wagonAllocations(bookingId: string): Promise { + return this.dataSource.query( + `SELECT tsw.sequence_no AS "sequenceNo", + w.wagon_number AS "wagonNumber", + COALESCE(wt.name, wt.code) AS "wagonType", + wt.code AS "wagonTypeCode", + wt.tare_weight_tons AS "tareWeightTons", + tsw.capacity_tons AS "capacityTons", + tsw.length_meters AS "lengthMeters", + a.allocated_weight_tons AS "allocatedWeightTons", + a.load_type AS "loadType", + a.status AS "status", + s.train_number AS "trainNumber", + s.scheduled_departure_date AS "departureAt", + so.label AS "originStation", + sd.label AS "destinationStation", + bl.cargo_description AS "bulkCargoDescription", + bl.quantity AS "bulkQuantity", + COALESCE( + json_agg( + json_build_object( + 'containerNumber', ci.container_number, + 'sealNumber', ci.seal_number, + 'positionOnWagon', ci.position_on_wagon, + 'grossWeightTons', ci.gross_weight_tons + ) ORDER BY ci.position_on_wagon, ci.container_number + ) FILTER (WHERE ci.id IS NOT NULL), + '[]' + ) AS "containers" + FROM freight.wagon_booking_allocations a + JOIN freight.train_set_wagons tsw + ON tsw.id = a.train_set_wagon_id AND tsw.deleted_at IS NULL + LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id + LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id + LEFT JOIN freight.train_schedules s + ON s.train_set_id = tsw.train_set_id AND s.deleted_at IS NULL + LEFT JOIN freight.yards so ON so.id = s.origin_station_id + LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id + LEFT JOIN freight.wagon_allocation_container_items ci + ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL + LEFT JOIN freight.wagon_allocation_bulk_loads bl + ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL + WHERE a.booking_id = $1 AND a.deleted_at IS NULL + GROUP BY tsw.id, a.id, w.wagon_number, wt.name, wt.code, wt.tare_weight_tons, + s.train_number, s.scheduled_departure_date, so.label, sd.label, + bl.cargo_description, bl.quantity + ORDER BY tsw.sequence_no`, + [bookingId], + ); + } + /** * Split the booking amount across its wagons, proportional to allocated weight * (equal shares when no weights are recorded). The last row absorbs the rounding diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index 17e824342..b8f279f59 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -6,6 +6,7 @@ import { FileText, LayoutGrid, Package, + TrainFront, Truck, XCircle, } from "lucide-react"; @@ -46,6 +47,7 @@ import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard"; import { ShipmentTrackingCard } from "./components/ShipmentTrackingCard"; import { StatusHero } from "./components/StatusHero"; import { SupportCard } from "./components/SupportCard"; +import { WagonsTab } from "./components/WagonsTab"; import { fmtDate, isNegative, priceTotal } from "./utils"; import { useScrollToHash } from "@/hooks/useScrollToHash"; import { useBookingPayment } from "@/pages/bookings/payments/useBookingPayment"; @@ -167,6 +169,9 @@ export function ReadonlyBookingView({ const showPairedNotice = !!booking.consolidationPartnerId && ["SUBMITTED", "PENDING_APPROVAL", "CHANGES_REQUESTED"].includes(status); + // Wagons exist only after payment puts the booking on a train; before that + // the tab would always be an empty state, so it stays hidden. + const showWagonsTab = booking.paymentStatus === "PAID" && !isNegative(status); return ( @@ -257,6 +262,11 @@ export function ReadonlyBookingView({ }> Cargo + {showWagonsTab && ( + }> + Wagons + + )} }> Logistics @@ -316,6 +326,12 @@ export function ReadonlyBookingView({ + {showWagonsTab && ( + + + + )} +
= { + PLANNED: { bg: "#F1F4F7", color: "#475569", label: "Planned" }, + RESERVED: { bg: "#FFFBEB", color: "#92400E", label: "Reserved" }, + LOADED: { bg: "#E8F5EF", color: "#0A6F4D", label: "Loaded" }, + DEPARTED: { bg: "#EAF1FE", color: "#1E40AF", label: "Departed" }, +}; + +function StatusPill({ status }: { status: BookingWagonAllocation["status"] }) { + const tone = STATUS_TONES[status] ?? STATUS_TONES.PLANNED; + return ( + + {tone.label} + + ); +} + +function StatTile({ + icon, + label, + value, + sub, +}: { + icon: ReactNode; + label: string; + value: string; + sub?: string; +}) { + return ( + + + {icon} + + {label} + + + + {value} + + {sub && ( + + {sub} + + )} + + ); +} + +/** Little consist strip: locomotive + one box per wagon, in marshalling order. */ +function ConsistStrip({ wagons }: { wagons: BookingWagonAllocation[] }) { + return ( + + + + + + LOCO + + + {wagons.map((w) => ( + + + + W{w.sequenceNo} + + + {w.wagonNumber ?? "—"} + + + + ))} + + + ); +} + +function LoadBar({ allocated, capacity }: { allocated: number; capacity: number }) { + const pct = capacity > 0 ? Math.min(100, Math.round((allocated / capacity) * 100)) : 0; + return ( + + + + Load + + + {fmtWeight(allocated)} + {capacity > 0 ? ` / ${fmtWeight(capacity)} · ${pct}%` : ""} + + + + = 95 ? "#B45309" : "#0A6F4D", + transition: "width 300ms ease", + }} + /> + + + ); +} + +const th = { color: "#9AA8B5", fontSize: 11 } as const; + +function WagonCard({ wagon }: { wagon: BookingWagonAllocation }) { + const allocated = Number(wagon.allocatedWeightTons || 0); + const capacity = Number(wagon.capacityTons || 0); + const containers = wagon.containers ?? []; + + return ( + + + + + + WAGON + + + {wagon.sequenceNo} + + + + + {wagon.wagonNumber ?? "Not yet assigned"} + + + {wagon.wagonType ?? "Wagon type pending"} + {wagon.wagonTypeCode && wagon.wagonType !== wagon.wagonTypeCode + ? ` · ${wagon.wagonTypeCode}` + : ""} + + + + + + + + + + {Number(wagon.tareWeightTons) > 0 && ( + + + + Tare {fmtWeight(Number(wagon.tareWeightTons))} + + + )} + {Number(wagon.lengthMeters) > 0 && ( + + + + {Number(wagon.lengthMeters)} m + + + )} + + {wagon.loadType === "BULK" ? ( + + ) : ( + + )} + + {wagon.loadType === "BULK" ? "Bulk load" : "Container load"} + + + + + {wagon.loadType === "BULK" && (wagon.bulkCargoDescription || wagon.bulkQuantity) && ( + + + {wagon.bulkCargoDescription ?? "Bulk cargo"} + + {Number(wagon.bulkQuantity) > 0 && ( + + Quantity: {Number(wagon.bulkQuantity).toLocaleString()} + + )} + + )} + + {containers.length > 0 && ( + + + + + Container no. + Seal no. + Gross wt. + + + + {containers.map((c, i) => ( + + + + {c.containerNumber ?? "—"} + + + + + {c.sealNumber ?? "—"} + + + + + {Number(c.grossWeightTons) > 0 + ? fmtWeight(Number(c.grossWeightTons)) + : "—"} + + + + ))} + +
+
+ )} +
+ ); +} + +/** + * "Wagons" tab: the customer's view of their allocated wagons once the paid + * booking has been placed on a train — consist strip in marshalling order, + * per-wagon load/containers, and the train's route summary. + */ +export function WagonsTab({ bookingId }: { bookingId: string }) { + const { data: wagons, isLoading } = useQuery({ + queryKey: ["booking-wagons", bookingId], + queryFn: () => bookingsService.getWagons(bookingId), + enabled: !!bookingId, + }); + + if (isLoading) { + return ( +
+ + + + + +
+ ); + } + + if (!wagons?.length) { + return ( + + + + + + + + No wagons allocated yet + + + Your wagons will appear here once the shipment is placed on a + train after payment. + + + + + ); + } + + const first = wagons[0]; + const totalAllocated = wagons.reduce( + (s, w) => s + Number(w.allocatedWeightTons || 0), + 0, + ); + const totalCapacity = wagons.reduce((s, w) => s + Number(w.capacityTons || 0), 0); + const containerCount = wagons.reduce((s, w) => s + (w.containers?.length ?? 0), 0); + const utilization = + totalCapacity > 0 ? Math.round((totalAllocated / totalCapacity) * 100) : null; + + return ( +
+ + + + + + + + + {first.trainNumber ? `Train ${first.trainNumber}` : "Your train"} + + + + + {first.originStation ?? "—"} → {first.destinationStation ?? "—"} + {first.departureAt ? ` · departs ${fmtDate(first.departureAt)}` : ""} + + + + + Your wagons on this train + + + + + + } + label="Wagons" + value={`${wagons.length}`} + sub="allocated to you" + /> + } + label="Allocated weight" + value={fmtWeight(totalAllocated)} + /> + } + label="Containers" + value={containerCount ? `${containerCount}` : "—"} + sub={containerCount ? "loaded on wagons" : undefined} + /> + } + label="Utilization" + value={utilization != null ? `${utilization}%` : "—"} + sub="of wagon capacity" + /> + + + + + {wagons.map((w) => ( + + ))} + +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index edcf8a76c..4aba2c734 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -193,6 +193,34 @@ export interface BookingListFilter { sortOrder?: "ASC" | "DESC"; } +export interface BookingWagonContainer { + containerNumber: string | null; + sealNumber: string | null; + positionOnWagon: number | null; + grossWeightTons: string | null; +} + +/** One allocated wagon of a booking, as returned by GET /bookings/:id/wagons. */ +export interface BookingWagonAllocation { + sequenceNo: number; + wagonNumber: string | null; + wagonType: string | null; + wagonTypeCode: string | null; + tareWeightTons: string | null; + capacityTons: string | null; + lengthMeters: string | null; + allocatedWeightTons: string | null; + loadType: "CONTAINER" | "BULK"; + status: "PLANNED" | "RESERVED" | "LOADED" | "DEPARTED"; + trainNumber: string | null; + departureAt: string | null; + originStation: string | null; + destinationStation: string | null; + bulkCargoDescription: string | null; + bulkQuantity: string | null; + containers: BookingWagonContainer[]; +} + export const bookingsService = { list: async ( filter: BookingListFilter | void = {}, @@ -558,6 +586,15 @@ export const bookingsService = { return data.data as Freight.DayAvailabilityResponse; }, + /** + * Allocated wagons for a paid booking (empty until placed on a train). + * One row per wagon with its containers / bulk load. + */ + getWagons: async (bookingId: string): Promise => { + const { data } = await client.get(`/api/bookings/${bookingId}/wagons`); + return (data.data ?? data) as BookingWagonAllocation[]; + }, + /** * Upcoming/open booking windows on the signed-in customer's active-contract * lanes (import booking-day windows + export 24h pre-departure windows).