diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ChangesRequestedView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ChangesRequestedView.tsx
index 0599d685e..6b4c8927d 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ChangesRequestedView.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ChangesRequestedView.tsx
@@ -21,6 +21,9 @@ import { useResubmitFlow } from "@/pages/bookings/resubmit/useResubmitFlow";
import { CardTitle, PageShell, SectionCard } from "./components/layout";
import { BodyGrid } from "./components/layout";
+import { CompanyInfoCard } from "./components/CompanyInfoCard";
+import { ContainersCard } from "./components/ContainersCard";
+import { ContractInfoCard } from "./components/ContractInfoCard";
import { ActionRequiredBanner, MutationErrors } from "./components/Notices";
import { PageHeader } from "./components/PageHeader";
import { EstimateCard } from "./components/pricing";
@@ -94,6 +97,10 @@ export function ChangesRequestedView({
<>
+
+
+
+
Your documents
@@ -143,6 +150,7 @@ export function ChangesRequestedView({
chip="Not invoiced"
/>
+
setCancelDialogOpen(true)} />
>
}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx
index d372f6e37..277bd1a24 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx
@@ -29,6 +29,9 @@ import type { Freight } from "@edr/types";
import { REQUIRED_DOC_FIELDS } from "./constants";
import { CardTitle, PageShell, SectionCard } from "./components/layout";
+import { CompanyInfoCard } from "./components/CompanyInfoCard";
+import { ContainersCard } from "./components/ContainersCard";
+import { ContractInfoCard } from "./components/ContractInfoCard";
import { CountChip, DocRow, IconSquare } from "./components/Documents";
import { EstimateCard } from "./components/pricing";
import { HeaderButton, PageHeader } from "./components/PageHeader";
@@ -241,6 +244,10 @@ export function DraftBookingView({
+
+
+
+
{/* Documents (uploadable) */}
@@ -399,6 +406,7 @@ export function DraftBookingView({
chip="Not invoiced"
/>
+
setCancelDialogOpen(true)} />
>
}
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 244a38739..fc701b924 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
@@ -16,8 +16,10 @@ import { PayClearanceFeeButton } from "../payments/PayClearanceFeeButton";
import { ActivityCard } from "./components/ActivityCard";
import { ClearanceCard } from "./components/ClearanceCard";
import { DocumentsTab } from "./components/DocumentsTab";
+import { CompanyInfoCard } from "./components/CompanyInfoCard";
import { ContainersCard } from "./components/ContainersCard";
import { ContractCard } from "./components/ContractCard";
+import { ContractInfoCard } from "./components/ContractInfoCard";
import { CustomerTruckAssignmentCard } from "./components/CustomerTruckAssignmentCard";
import { KeyFactsStrip } from "./components/KeyFactsStrip";
import { MileSummaryCard } from "./components/MileSummaryCard";
@@ -271,6 +273,8 @@ export function ReadonlyBookingView({
+
+
{canAssignCustomerTruck && (
@@ -300,6 +304,7 @@ export function ReadonlyBookingView({
title="Consignment & Schedule"
consignment
/>
+
>
}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/booking-detail-types.ts b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/booking-detail-types.ts
new file mode 100644
index 000000000..7479e758d
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/booking-detail-types.ts
@@ -0,0 +1,93 @@
+import type { Freight } from "@edr/types";
+
+/**
+ * `GET /api/bookings/:id` serializes the raw TypeORM `Booking` entity with its
+ * relations attached (company, bookingContainers → units, shippingLine,
+ * cargoType…). That's a strict superset of the `Freight.IBooking` DTO, which
+ * doesn't declare these relations (and still lists a couple of fields —
+ * `freightSubtype`, the string-enum `serviceType` — that the API never
+ * actually sends). This augments the shared type with what the endpoint
+ * really returns so the detail page can render it without unsafe casts.
+ */
+
+export interface BookingContainerUnitDetail {
+ id: string;
+ containerNumber: string;
+ sealNumber?: string | null;
+ vgmTons: number | string;
+ isHazardous?: boolean;
+ isReefer?: boolean;
+ isReturn?: boolean;
+ receivedToPort?: boolean;
+ receivedAt?: string | null;
+ grnNumber?: string | null;
+ sortOrder?: number;
+}
+
+export interface BookingContainerLineDetail {
+ id: string;
+ quantity: number;
+ vgmPerUnitTons: number | string;
+ totalVgmTons: number | string;
+ hazardousQuantity?: number;
+ reeferQuantity?: number;
+ returnQuantity?: number;
+ isOverweight?: boolean;
+ overweightExcessTons?: number | string | null;
+ containerNumber?: string | null;
+ containerType?: {
+ code: string;
+ label?: string | null;
+ sizeFt?: number | null;
+ isReefer?: boolean | null;
+ } | null;
+ units?: BookingContainerUnitDetail[];
+}
+
+export interface BookingShippingLineDetail {
+ code: string;
+ label: string;
+ showExtraFeeNotice?: boolean;
+}
+
+export interface BookingCargoTypeDetail {
+ code: string;
+ cargoTypeName: string;
+ unitOfMeasure?: string | null;
+}
+
+/** The API's real (object) shape for the joined service-type relation. */
+export type BookingServiceTypeRef = NonNullable;
+
+export type BookingDetail = Freight.IBooking & {
+ /** Billed-to company relation, always joined on the detail endpoint. */
+ company?: Freight.BookingRequestCompany | null;
+ isGovernment?: boolean;
+ governmentInstitution?: string | null;
+ /** Real container line-items (with per-unit numbers/seals/VGM). */
+ bookingContainers?: BookingContainerLineDetail[] | null;
+ shippingLine?: BookingShippingLineDetail | null;
+ cargoType?: BookingCargoTypeDetail | null;
+ cargoFreeText?: string | null;
+ /** Joined paired-booking relation (consolidation partner), not just its id. */
+ consolidationPartner?: {
+ id: string;
+ reference: string;
+ status: string;
+ } | null;
+};
+
+/**
+ * `booking.serviceType` is declared as the legacy `"RAIL_ONLY" |
+ * "RAIL_AND_FORWARDING"` string enum on `Freight.IBooking`, but the API
+ * actually sends the joined ServiceType relation object (`{ code,
+ * serviceName, includesFirstMile, includesLastMile, includesCustoms, … }`).
+ * Read it through this helper instead of comparing directly — see
+ * `serviceTypeLabel()` in `utils.ts`.
+ */
+export function rawServiceType(
+ booking: BookingDetail,
+): string | BookingServiceTypeRef | null | undefined {
+ return (booking as unknown as { serviceType?: string | BookingServiceTypeRef | null })
+ .serviceType;
+}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CompanyInfoCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CompanyInfoCard.tsx
new file mode 100644
index 000000000..bdcdb71fd
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CompanyInfoCard.tsx
@@ -0,0 +1,117 @@
+import { Box, Divider, Group, Text } from "@mantine/core";
+import { Building2, Landmark, Mail, MapPin, Phone, User } from "lucide-react";
+import type { ReactNode } from "react";
+
+import type { BookingDetail } from "../booking-detail-types";
+import { CardTitle, SectionCard } from "./layout";
+
+function InfoRow({
+ icon,
+ label,
+ value,
+}: {
+ icon: ReactNode;
+ label: string;
+ value: string;
+}) {
+ return (
+
+
+ {icon}
+
+
+
+ {label}
+
+
+ {value}
+
+
+
+ );
+}
+
+/**
+ * Customer/company information billed on this booking — the joined
+ * `company` relation the detail endpoint always returns (name, TIN, contact
+ * details), which the previous UI never surfaced at all.
+ */
+export function CompanyInfoCard({ booking }: { booking: BookingDetail }) {
+ const company = booking.company;
+ if (!company) return null;
+
+ const contact = company.contactPersonName
+ ? company.contactPersonPhone
+ ? `${company.contactPersonName} · ${company.contactPersonPhone}`
+ : company.contactPersonName
+ : null;
+
+ return (
+
+
+ Customer Information
+ {booking.isGovernment && (
+
+
+ Government
+
+ )}
+
+
+ {company.name || "—"}
+
+ {booking.governmentInstitution && (
+
+ {booking.governmentInstitution}
+
+ )}
+
+
+
+
+ {company.tin && (
+ } label="TIN" value={company.tin} />
+ )}
+ {company.email && (
+ } label="Email" value={company.email} />
+ )}
+ {company.phone && (
+ } label="Phone" value={company.phone} />
+ )}
+ {company.address && (
+ } label="Address" value={company.address} />
+ )}
+ {contact && (
+ } label="Contact person" value={contact} />
+ )}
+
+
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContainersCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContainersCard.tsx
index 75fe65ec7..abf9e72eb 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContainersCard.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContainersCard.tsx
@@ -1,22 +1,99 @@
import { Box, Group, Table, Text } from "@mantine/core";
+import { AlertTriangle, Flame, Snowflake, Undo2 } from "lucide-react";
+import type { ReactNode } from "react";
-import type { Freight } from "@edr/types";
-
+import type {
+ BookingContainerLineDetail,
+ BookingContainerUnitDetail,
+ BookingDetail,
+} from "../booking-detail-types";
+import { fmtWeight, totalVgmTons } from "../utils";
import { CardTitle, SectionCard } from "./layout";
-/**
- * Per-container-type breakdown for container bookings (count, type, VGM).
- * Renders nothing for bulk bookings, which have no container lines.
- */
-export function ContainersCard({ booking }: { booking: Freight.IBooking }) {
- const containers = booking.containers ?? [];
- if (booking.freightType === "BULK" || containers.length === 0) return null;
+function containerTypeLabel(line: BookingContainerLineDetail): string {
+ const t = line.containerType;
+ if (t?.label) return t.label;
+ if (t?.sizeFt) return `${t.sizeFt}ft${t.isReefer ? " Reefer" : ""} container`;
+ return t?.code ?? "Container";
+}
- const totalUnits = containers.reduce((sum, c) => sum + Number(c.qty || 0), 0);
- const totalVgm = containers.reduce(
- (sum, c) => sum + Number(c.vgm || 0) * Number(c.qty || 0),
- 0,
+function Flag({ icon, label }: { icon: ReactNode; label: string }) {
+ return (
+
+ {icon}
+ {label}
+
);
+}
+
+function UnitRow({ unit }: { unit: BookingContainerUnitDetail }) {
+ return (
+
+
+
+ {unit.containerNumber}
+
+
+
+
+ {unit.sealNumber || "—"}
+
+
+
+
+ {Number(unit.vgmTons || 0) ? `${Number(unit.vgmTons).toLocaleString()} t` : "—"}
+
+
+
+
+ {unit.isHazardous && (
+ } label="Hazardous" />
+ )}
+ {unit.isReefer && } label="Reefer" />}
+ {unit.isReturn && } label="Return" />}
+ {!unit.isHazardous && !unit.isReefer && !unit.isReturn && (
+
+ —
+
+ )}
+
+
+
+
+ {unit.receivedToPort ? "Received" : "Pending"}
+
+
+
+ );
+}
+
+/**
+ * Per-container breakdown for container bookings — real per-line data
+ * (`bookingContainers`, joined with per-unit numbers/seals/VGM) rather than
+ * the legacy `booking.containers` DTO shape, which the detail endpoint
+ * never populates. Renders nothing for bulk bookings.
+ */
+export function ContainersCard({ booking }: { booking: BookingDetail }) {
+ const lines = booking.bookingContainers ?? [];
+ if (booking.freightType === "BULK" || lines.length === 0) return null;
+
+ const totalUnits = lines.reduce((sum, c) => sum + Number(c.quantity || 0), 0);
+ const totalVgm = totalVgmTons(booking);
+ const allUnits = lines.flatMap((l) => l.units ?? []);
return (
@@ -27,7 +104,7 @@ export function ContainersCard({ booking }: { booking: Freight.IBooking }) {
-
+
Type
@@ -39,28 +116,39 @@ export function ContainersCard({ booking }: { booking: Freight.IBooking }) {
- {containers.map((c, i) => {
- const lineVgm = Number(c.vgm || 0) * Number(c.qty || 0);
+ {lines.map((c, i) => {
+ const lineVgm = Number(c.totalVgmTons || 0);
return (
-
+
-
- {c.type}
-
+
+
+ {containerTypeLabel(c)}
+
+ {c.isOverweight && (
+ } label="Overweight" />
+ )}
+ {!!c.hazardousQuantity && (
+ } label={`${c.hazardousQuantity} hazardous`} />
+ )}
+ {!!c.reeferQuantity && (
+ } label={`${c.reeferQuantity} reefer`} />
+ )}
+
- {c.qty}
+ {c.quantity}
- {c.vgm ? `${c.vgm} t` : "—"}
+ {fmtWeight(Number(c.vgmPerUnitTons || 0))}
- {lineVgm ? `${lineVgm.toLocaleString()} t` : "—"}
+ {fmtWeight(lineVgm)}
@@ -69,6 +157,30 @@ export function ContainersCard({ booking }: { booking: Freight.IBooking }) {
+ {allUnits.length > 0 && (
+
+
+ Container numbers
+
+
+
+
+ Container no.
+ Seal no.
+ VGM
+ Flags
+ Port status
+
+
+
+ {allUnits.map((u) => (
+
+ ))}
+
+
+
+ )}
+
- {totalVgm.toLocaleString()} t
+ {fmtWeight(totalVgm)}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContractInfoCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContractInfoCard.tsx
new file mode 100644
index 000000000..f86925fc6
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContractInfoCard.tsx
@@ -0,0 +1,196 @@
+import { Anchor, Box, Divider, Group, Text } from "@mantine/core";
+import { FileText, Link2 } from "lucide-react";
+import type { ReactNode } from "react";
+import { Link } from "react-router-dom";
+
+import type { BookingDetail } from "../booking-detail-types";
+import { fmtDate } from "../utils";
+import { CardTitle, SectionCard } from "./layout";
+
+function Field({ label, value }: { label: string; value: ReactNode }) {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ );
+}
+
+function Row({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * Contract terms for this booking — validity window, financial terms,
+ * customs/currency, renewal chain — none of which the detail page surfaced
+ * before even though the booking always carries them.
+ */
+export function ContractInfoCard({ booking }: { booking: BookingDetail }) {
+ const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT";
+ const hasValidity = booking.contractValidFrom || booking.contractValidUntil;
+
+ return (
+
+
+ Contract Information
+ {booking.contractId && (
+
+ View full contract →
+
+ )}
+
+
+
+
+
+ {booking.contractReference ?? booking.reference}
+
+ ) : (
+ (booking.contractReference ?? booking.reference)
+ )
+ }
+ />
+
+
+
+
+
+
+
+
+ {hasValidity && (
+
+
+
+
+ )}
+
+ {isGeneralContract && (booking.startDate || booking.endDate) && (
+
+
+
+
+ )}
+
+ {isGeneralContract && booking.expiresAt && (
+
+
+
+
+ )}
+
+ {booking.customsClearingEnabled && (
+
+
+
+
+ )}
+
+ {booking.previousContractId && (
+
+
+
+
+ Previous contract
+
+
+ }
+ />
+
+
+ )}
+
+
+ {booking.financialTerms && (
+ <>
+
+
+ Financial terms
+
+
+ {booking.financialTerms}
+
+ >
+ )}
+
+ {booking.contractSummary && (
+ <>
+
+
+
+
+ Contract summary
+
+
+
+ {booking.contractSummary}
+
+ >
+ )}
+
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ScheduleCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ScheduleCard.tsx
index 7a6323a58..fc7935962 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ScheduleCard.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ScheduleCard.tsx
@@ -1,11 +1,10 @@
import { Box, Group, Text } from "@mantine/core";
import type { ReactNode } from "react";
-import type { Freight } from "@edr/types";
-
import { bookingStatusLabel } from "@/pages/bookings/booking-display";
-import { fmtDate, isDraftLike, isNegative } from "../utils";
+import type { BookingDetail } from "../booking-detail-types";
+import { fmtDate, isDraftLike, isNegative, serviceTypeLabel } from "../utils";
import { CardTitle, SectionCard } from "./layout";
type Row = { label: string; value: ReactNode; muted?: boolean };
@@ -50,14 +49,11 @@ export function ScheduleCard({
title,
consignment,
}: {
- booking: Freight.IBooking;
+ booking: BookingDetail;
title: string;
consignment?: boolean;
}) {
- const service =
- booking.serviceType === "RAIL_AND_FORWARDING"
- ? "Rail + Forwarding"
- : "Rail only";
+ const service = serviceTypeLabel(booking);
const equipmentReturn =
booking.equipmentReturn === "WITH_RETURN" ? "With return" : "Without return";
const assignedTrain: Row = {
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ShipmentDetailsCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ShipmentDetailsCard.tsx
index d15730541..8051e9986 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ShipmentDetailsCard.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ShipmentDetailsCard.tsx
@@ -1,12 +1,45 @@
import { Box, Group, Text } from "@mantine/core";
import { FileText } from "lucide-react";
-import type { Freight } from "@edr/types";
-
-import { containerSummary, fmtDate, yardLabel } from "../utils";
+import type { BookingDetail } from "../booking-detail-types";
+import {
+ commodityLabel,
+ containerSummary,
+ fmtDate,
+ fmtWeight,
+ serviceTypeLabel,
+ shippingLineLabel,
+ totalVgmTons,
+ yardLabel,
+} from "../utils";
import { CardTitle, SectionCard } from "./layout";
-export function ShipmentDetailsCard({ booking }: { booking: Freight.IBooking }) {
+function Badge({ label, tone }: { label: string; tone: "amber" | "blue" }) {
+ const palette =
+ tone === "amber"
+ ? { bg: "#FFFBEB", border: "#FDE68A", color: "#92400E" }
+ : { bg: "#EAF1FE", border: "#CFDDFB", color: "#1E40AF" };
+ return (
+
+ {label}
+
+ );
+}
+
+export function ShipmentDetailsCard({ booking }: { booking: BookingDetail }) {
+ const weight = totalVgmTons(booking);
const rows: [string, string][][] = [
[
["Origin yard", yardLabel(booking.originYard)],
@@ -14,22 +47,14 @@ export function ShipmentDetailsCard({ booking }: { booking: Freight.IBooking })
],
[
["Freight type", booking.freightType === "BULK" ? "Bulk" : "Container"],
- ["Commodity", booking.freightSubtype || "—"],
+ ["Commodity", commodityLabel(booking)],
],
[
["Containers / load", containerSummary(booking)],
- [
- "Total weight (VGM)",
- booking.cargoTotalWeightVgm ? `${booking.cargoTotalWeightVgm} t` : "—",
- ],
+ ["Total weight (VGM)", fmtWeight(weight)],
],
[
- [
- "Service type",
- booking.serviceType === "RAIL_AND_FORWARDING"
- ? "Rail + Forwarding"
- : "Rail only",
- ],
+ ["Service type", serviceTypeLabel(booking)],
[
"Equipment return",
booking.equipmentReturn === "WITH_RETURN"
@@ -44,32 +69,49 @@ export function ShipmentDetailsCard({ booking }: { booking: Freight.IBooking })
],
["Scheduled date", fmtDate(booking.scheduledDate)],
],
- [["Assigned train", booking.trainId ?? "Not yet assigned"]],
+ [
+ ["Shipping line", shippingLineLabel(booking)],
+ ["Assigned train", booking.trainId ?? "Not yet assigned"],
+ ],
];
+ const badges: string[] = [];
+ if (booking.isHazardous) badges.push("Hazardous");
+ if (booking.isRefrigerated) badges.push("Refrigerated");
+ if (booking.customsClearingEnabled) badges.push("Customs clearance");
+
return (
Shipment Details
-
-
- {booking.contractType === "RENEWAL"
- ? "Renewal contract"
- : "New contract"}
+
+ {badges.map((b) => (
+
+ ))}
+
+
+ {booking.contractType === "RENEWAL"
+ ? "Renewal contract"
+ : "New contract"}
+
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts
index 432dcf8f5..924ee16a4 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts
@@ -2,6 +2,8 @@ import { format } from "date-fns";
import type { Freight } from "@edr/types";
+import { rawServiceType, type BookingDetail } from "./booking-detail-types";
+
export const isNegative = (s: string) => s === "CANCELLED" || s === "REJECTED";
export const isDraftLike = (s: string) =>
s === "DRAFT" || s === "CHANGES_REQUESTED";
@@ -37,17 +39,105 @@ export function yardLabel(y?: Freight.IBooking["originYard"]) {
return y?.label ?? y?.code ?? "—";
}
-export function containerSummary(b: Freight.IBooking) {
+function containerLineLabel(c: NonNullable[number]) {
+ const t = c.containerType;
+ if (t?.label) return t.label;
+ if (t?.sizeFt) return `${t.sizeFt}ft${t.isReefer ? " Reefer" : ""}`;
+ return t?.code ?? "Container";
+}
+
+/**
+ * The real per-line container data lives on `bookingContainers` (joined
+ * relation, with per-unit numbers + VGM) — `booking.containers` is a
+ * frontend-only DTO shape the create/update flows use that the detail
+ * endpoint never populates, so it's kept only as a last-resort fallback.
+ */
+export function containerSummary(b: BookingDetail) {
+ const lines = b.bookingContainers ?? [];
+ if (lines.length > 0) {
+ return lines.map((c) => `${c.quantity} × ${containerLineLabel(c)}`).join(", ");
+ }
if (b.containers?.length) {
return b.containers.map((c) => `${c.qty} × ${c.type}`).join(", ");
}
return b.freightType === "BULK" ? "Bulk cargo" : "—";
}
-export function bookingSubtitle(b: Freight.IBooking) {
- const cargo =
+/**
+ * Total shipped weight (VGM), in tons. Container bookings compute the real
+ * total from `bookingContainers[].totalVgmTons` (per-line quantity × VGM)
+ * because `cargoTotalWeightVgm` is often left at 0 for container freight —
+ * the VGM is captured per container, not as a single booking-level figure.
+ * Falls back to `cargoTotalWeightVgm` for bulk freight / legacy rows.
+ */
+export function totalVgmTons(b: BookingDetail): number {
+ const lines = b.bookingContainers ?? [];
+ if (lines.length > 0) {
+ const sum = lines.reduce((s, c) => s + Number(c.totalVgmTons || 0), 0);
+ if (sum > 0) return sum;
+ }
+ if (b.containers?.length) {
+ const sum = b.containers.reduce(
+ (s, c) => s + Number(c.vgm || 0) * Number(c.qty || 0),
+ 0,
+ );
+ if (sum > 0) return sum;
+ }
+ return Number(b.cargoTotalWeightVgm || 0);
+}
+
+export function fmtWeight(tons: number): string {
+ return tons > 0 ? `${tons.toLocaleString(undefined, { maximumFractionDigits: 3 })} t` : "—";
+}
+
+/**
+ * `booking.serviceType` is declared as the legacy "RAIL_ONLY" |
+ * "RAIL_AND_FORWARDING" string on the shared type, but the API sends the
+ * joined ServiceType relation object. Handle both shapes, with a fallback
+ * derived from the first/last-mile addresses when neither is present.
+ */
+export function serviceTypeLabel(b: BookingDetail): string {
+ const st = rawServiceType(b);
+ if (st && typeof st === "object") {
+ if (st.serviceName) return st.serviceName;
+ if (st.code) {
+ return st.code
+ .replace(/_/g, " ")
+ .toLowerCase()
+ .replace(/\b\w/g, (m) => m.toUpperCase());
+ }
+ }
+ if (typeof st === "string") {
+ return st === "RAIL_AND_FORWARDING" ? "Rail + Forwarding" : "Rail only";
+ }
+ return b.firstMilePickupAddress || b.lastMileDeliveryAddress
+ ? "Rail + Forwarding"
+ : "Rail only";
+}
+
+/** Real commodity name from the joined cargo type, falling back to the
+ * free-text commodity entered at booking time. `freightSubtype` is a
+ * legacy field the API no longer sends. */
+export function commodityLabel(b: BookingDetail): string {
+ return (
+ b.cargoType?.cargoTypeName ||
+ b.cargoFreeText ||
b.freightSubtype ||
- (b.freightType === "BULK" ? "Bulk freight" : "Container freight");
+ "—"
+ );
+}
+
+export function shippingLineLabel(b: BookingDetail): string {
+ return b.shippingLine?.label || b.shippingLine?.code || "—";
+}
+
+export function bookingSubtitle(b: BookingDetail) {
+ const cargo =
+ commodityLabel(b) !== "—"
+ ? commodityLabel(b)
+ : b.freightType === "BULK"
+ ? "Bulk freight"
+ : "Container freight";
const load = containerSummary(b);
const route = `${yardLabel(b.originYard)} → ${yardLabel(b.destinationYard)}`;
return [cargo, load, route].filter((p) => p && p !== "—").join(" · ");