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(" · ");