mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 22:25:42 +00:00
Add container and contract information cards to booking detail views
- Integrated CompanyInfoCard, ContainersCard, and ContractInfoCard into ChangesRequestedView, DraftBookingView, and ReadonlyBookingView components. - Updated utils to handle new booking detail types and improve container summary calculations. - Enhanced ShipmentDetailsCard and ScheduleCard to utilize updated booking detail structure and display relevant information more effectively.
This commit is contained in:
@@ -21,6 +21,9 @@ import { useResubmitFlow } from "@/pages/bookings/resubmit/useResubmitFlow";
|
|||||||
|
|
||||||
import { CardTitle, PageShell, SectionCard } from "./components/layout";
|
import { CardTitle, PageShell, SectionCard } from "./components/layout";
|
||||||
import { BodyGrid } 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 { ActionRequiredBanner, MutationErrors } from "./components/Notices";
|
||||||
import { PageHeader } from "./components/PageHeader";
|
import { PageHeader } from "./components/PageHeader";
|
||||||
import { EstimateCard } from "./components/pricing";
|
import { EstimateCard } from "./components/pricing";
|
||||||
@@ -94,6 +97,10 @@ export function ChangesRequestedView({
|
|||||||
<>
|
<>
|
||||||
<ShipmentDetailsCard booking={booking} />
|
<ShipmentDetailsCard booking={booking} />
|
||||||
|
|
||||||
|
<ContainersCard booking={booking} />
|
||||||
|
|
||||||
|
<ContractInfoCard booking={booking} />
|
||||||
|
|
||||||
<SectionCard>
|
<SectionCard>
|
||||||
<Group justify="space-between" align="center" mb="md">
|
<Group justify="space-between" align="center" mb="md">
|
||||||
<CardTitle>Your documents</CardTitle>
|
<CardTitle>Your documents</CardTitle>
|
||||||
@@ -143,6 +150,7 @@ export function ChangesRequestedView({
|
|||||||
chip="Not invoiced"
|
chip="Not invoiced"
|
||||||
/>
|
/>
|
||||||
<ScheduleCard booking={booking} title="Schedule & Service" />
|
<ScheduleCard booking={booking} title="Schedule & Service" />
|
||||||
|
<CompanyInfoCard booking={booking} />
|
||||||
<SupportCard onCancel={() => setCancelDialogOpen(true)} />
|
<SupportCard onCancel={() => setCancelDialogOpen(true)} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ import type { Freight } from "@edr/types";
|
|||||||
|
|
||||||
import { REQUIRED_DOC_FIELDS } from "./constants";
|
import { REQUIRED_DOC_FIELDS } from "./constants";
|
||||||
import { CardTitle, PageShell, SectionCard } from "./components/layout";
|
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 { CountChip, DocRow, IconSquare } from "./components/Documents";
|
||||||
import { EstimateCard } from "./components/pricing";
|
import { EstimateCard } from "./components/pricing";
|
||||||
import { HeaderButton, PageHeader } from "./components/PageHeader";
|
import { HeaderButton, PageHeader } from "./components/PageHeader";
|
||||||
@@ -241,6 +244,10 @@ export function DraftBookingView({
|
|||||||
|
|
||||||
<ShipmentDetailsCard booking={booking} />
|
<ShipmentDetailsCard booking={booking} />
|
||||||
|
|
||||||
|
<ContainersCard booking={booking} />
|
||||||
|
|
||||||
|
<ContractInfoCard booking={booking} />
|
||||||
|
|
||||||
{/* Documents (uploadable) */}
|
{/* Documents (uploadable) */}
|
||||||
<SectionCard ref={documentsRef}>
|
<SectionCard ref={documentsRef}>
|
||||||
<Group justify="space-between" align="center" mb="md">
|
<Group justify="space-between" align="center" mb="md">
|
||||||
@@ -399,6 +406,7 @@ export function DraftBookingView({
|
|||||||
chip="Not invoiced"
|
chip="Not invoiced"
|
||||||
/>
|
/>
|
||||||
<ScheduleCard booking={booking} title="Schedule & Service" />
|
<ScheduleCard booking={booking} title="Schedule & Service" />
|
||||||
|
<CompanyInfoCard booking={booking} />
|
||||||
<SupportCard onCancel={() => setCancelDialogOpen(true)} />
|
<SupportCard onCancel={() => setCancelDialogOpen(true)} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ import { PayClearanceFeeButton } from "../payments/PayClearanceFeeButton";
|
|||||||
import { ActivityCard } from "./components/ActivityCard";
|
import { ActivityCard } from "./components/ActivityCard";
|
||||||
import { ClearanceCard } from "./components/ClearanceCard";
|
import { ClearanceCard } from "./components/ClearanceCard";
|
||||||
import { DocumentsTab } from "./components/DocumentsTab";
|
import { DocumentsTab } from "./components/DocumentsTab";
|
||||||
|
import { CompanyInfoCard } from "./components/CompanyInfoCard";
|
||||||
import { ContainersCard } from "./components/ContainersCard";
|
import { ContainersCard } from "./components/ContainersCard";
|
||||||
import { ContractCard } from "./components/ContractCard";
|
import { ContractCard } from "./components/ContractCard";
|
||||||
|
import { ContractInfoCard } from "./components/ContractInfoCard";
|
||||||
import { CustomerTruckAssignmentCard } from "./components/CustomerTruckAssignmentCard";
|
import { CustomerTruckAssignmentCard } from "./components/CustomerTruckAssignmentCard";
|
||||||
import { KeyFactsStrip } from "./components/KeyFactsStrip";
|
import { KeyFactsStrip } from "./components/KeyFactsStrip";
|
||||||
import { MileSummaryCard } from "./components/MileSummaryCard";
|
import { MileSummaryCard } from "./components/MileSummaryCard";
|
||||||
@@ -271,6 +273,8 @@ export function ReadonlyBookingView({
|
|||||||
|
|
||||||
<ContainersCard booking={booking} />
|
<ContainersCard booking={booking} />
|
||||||
|
|
||||||
|
<ContractInfoCard booking={booking} />
|
||||||
|
|
||||||
<ShipmentTrackingCard bookingId={booking.id} />
|
<ShipmentTrackingCard bookingId={booking.id} />
|
||||||
|
|
||||||
{canAssignCustomerTruck && (
|
{canAssignCustomerTruck && (
|
||||||
@@ -300,6 +304,7 @@ export function ReadonlyBookingView({
|
|||||||
title="Consignment & Schedule"
|
title="Consignment & Schedule"
|
||||||
consignment
|
consignment
|
||||||
/>
|
/>
|
||||||
|
<CompanyInfoCard booking={booking} />
|
||||||
<SupportCard />
|
<SupportCard />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Freight.IContract["serviceType"]>;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Group gap={10} align="flex-start" wrap="nowrap" py={8}>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: 9,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: "#F1F4F7",
|
||||||
|
color: "#475569",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</Box>
|
||||||
|
<Box miw={0} flex={1}>
|
||||||
|
<Text fz="10.5px" fw={600} c="#9AA8B5" tt="uppercase" style={{ letterSpacing: "0.04em" }}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text mt={2} fz="13.5px" fw={700} c="#10202F" style={{ wordBreak: "break-word" }}>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<SectionCard>
|
||||||
|
<Group justify="space-between" align="center" mb={4}>
|
||||||
|
<CardTitle>Customer Information</CardTitle>
|
||||||
|
{booking.isGovernment && (
|
||||||
|
<Group
|
||||||
|
component="span"
|
||||||
|
gap={5}
|
||||||
|
align="center"
|
||||||
|
wrap="nowrap"
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: "#EAF1FE",
|
||||||
|
border: "1px solid #CFDDFB",
|
||||||
|
padding: "3px 10px",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#1E40AF",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Landmark size={12} />
|
||||||
|
Government
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Text fz="16px" fw={800} c="#10202F" mt={6}>
|
||||||
|
{company.name || "—"}
|
||||||
|
</Text>
|
||||||
|
{booking.governmentInstitution && (
|
||||||
|
<Text fz="12.5px" c="#6B7C8E" mt={2}>
|
||||||
|
{booking.governmentInstitution}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider my={10} color="#F2F5F8" />
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
{company.tin && (
|
||||||
|
<InfoRow icon={<Building2 size={15} />} label="TIN" value={company.tin} />
|
||||||
|
)}
|
||||||
|
{company.email && (
|
||||||
|
<InfoRow icon={<Mail size={15} />} label="Email" value={company.email} />
|
||||||
|
)}
|
||||||
|
{company.phone && (
|
||||||
|
<InfoRow icon={<Phone size={15} />} label="Phone" value={company.phone} />
|
||||||
|
)}
|
||||||
|
{company.address && (
|
||||||
|
<InfoRow icon={<MapPin size={15} />} label="Address" value={company.address} />
|
||||||
|
)}
|
||||||
|
{contact && (
|
||||||
|
<InfoRow icon={<User size={15} />} label="Contact person" value={contact} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,22 +1,99 @@
|
|||||||
import { Box, Group, Table, Text } from "@mantine/core";
|
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";
|
import { CardTitle, SectionCard } from "./layout";
|
||||||
|
|
||||||
/**
|
function containerTypeLabel(line: BookingContainerLineDetail): string {
|
||||||
* Per-container-type breakdown for container bookings (count, type, VGM).
|
const t = line.containerType;
|
||||||
* Renders nothing for bulk bookings, which have no container lines.
|
if (t?.label) return t.label;
|
||||||
*/
|
if (t?.sizeFt) return `${t.sizeFt}ft${t.isReefer ? " Reefer" : ""} container`;
|
||||||
export function ContainersCard({ booking }: { booking: Freight.IBooking }) {
|
return t?.code ?? "Container";
|
||||||
const containers = booking.containers ?? [];
|
}
|
||||||
if (booking.freightType === "BULK" || containers.length === 0) return null;
|
|
||||||
|
|
||||||
const totalUnits = containers.reduce((sum, c) => sum + Number(c.qty || 0), 0);
|
function Flag({ icon, label }: { icon: ReactNode; label: string }) {
|
||||||
const totalVgm = containers.reduce(
|
return (
|
||||||
(sum, c) => sum + Number(c.vgm || 0) * Number(c.qty || 0),
|
<Group
|
||||||
0,
|
component="span"
|
||||||
|
gap={4}
|
||||||
|
align="center"
|
||||||
|
wrap="nowrap"
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: "#F1F4F7",
|
||||||
|
padding: "2px 8px",
|
||||||
|
fontSize: 10.5,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#475569",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</Group>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UnitRow({ unit }: { unit: BookingContainerUnitDetail }) {
|
||||||
|
return (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz={13} fw={700} c="#10202F">
|
||||||
|
{unit.containerNumber}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz={13} c="#475569">
|
||||||
|
{unit.sealNumber || "—"}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz={13} c="#475569">
|
||||||
|
{Number(unit.vgmTons || 0) ? `${Number(unit.vgmTons).toLocaleString()} t` : "—"}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap={4} wrap="wrap">
|
||||||
|
{unit.isHazardous && (
|
||||||
|
<Flag icon={<AlertTriangle size={11} />} label="Hazardous" />
|
||||||
|
)}
|
||||||
|
{unit.isReefer && <Flag icon={<Snowflake size={11} />} label="Reefer" />}
|
||||||
|
{unit.isReturn && <Flag icon={<Undo2 size={11} />} label="Return" />}
|
||||||
|
{!unit.isHazardous && !unit.isReefer && !unit.isReturn && (
|
||||||
|
<Text fz={12} c="#9AA8B5">
|
||||||
|
—
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz={12.5} fw={600} c={unit.receivedToPort ? "#0A6F4D" : "#9AA8B5"}>
|
||||||
|
{unit.receivedToPort ? "Received" : "Pending"}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
return (
|
||||||
<SectionCard>
|
<SectionCard>
|
||||||
@@ -27,7 +104,7 @@ export function ContainersCard({ booking }: { booking: Freight.IBooking }) {
|
|||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Table verticalSpacing="sm" horizontalSpacing={0}>
|
<Table verticalSpacing="sm" horizontalSpacing={0} mb={allUnits.length ? "lg" : 0}>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th style={{ color: "#9AA8B5", fontSize: 11.5 }}>Type</Table.Th>
|
<Table.Th style={{ color: "#9AA8B5", fontSize: 11.5 }}>Type</Table.Th>
|
||||||
@@ -39,28 +116,39 @@ export function ContainersCard({ booking }: { booking: Freight.IBooking }) {
|
|||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{containers.map((c, i) => {
|
{lines.map((c, i) => {
|
||||||
const lineVgm = Number(c.vgm || 0) * Number(c.qty || 0);
|
const lineVgm = Number(c.totalVgmTons || 0);
|
||||||
return (
|
return (
|
||||||
<Table.Tr key={`${c.type}-${i}`}>
|
<Table.Tr key={c.id ?? i}>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text fz={14} fw={700} c="#10202F">
|
<Group gap={8} wrap="wrap">
|
||||||
{c.type}
|
<Text fz={14} fw={700} c="#10202F">
|
||||||
</Text>
|
{containerTypeLabel(c)}
|
||||||
|
</Text>
|
||||||
|
{c.isOverweight && (
|
||||||
|
<Flag icon={<AlertTriangle size={11} />} label="Overweight" />
|
||||||
|
)}
|
||||||
|
{!!c.hazardousQuantity && (
|
||||||
|
<Flag icon={<Flame size={11} />} label={`${c.hazardousQuantity} hazardous`} />
|
||||||
|
)}
|
||||||
|
{!!c.reeferQuantity && (
|
||||||
|
<Flag icon={<Snowflake size={11} />} label={`${c.reeferQuantity} reefer`} />
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text fz={14} c="#10202F">
|
<Text fz={14} c="#10202F">
|
||||||
{c.qty}
|
{c.quantity}
|
||||||
</Text>
|
</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text fz={14} c="#475569">
|
<Text fz={14} c="#475569">
|
||||||
{c.vgm ? `${c.vgm} t` : "—"}
|
{fmtWeight(Number(c.vgmPerUnitTons || 0))}
|
||||||
</Text>
|
</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text fz={14} fw={700} c="#10202F" ta="right">
|
<Text fz={14} fw={700} c="#10202F" ta="right">
|
||||||
{lineVgm ? `${lineVgm.toLocaleString()} t` : "—"}
|
{fmtWeight(lineVgm)}
|
||||||
</Text>
|
</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
@@ -69,6 +157,30 @@ export function ContainersCard({ booking }: { booking: Freight.IBooking }) {
|
|||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
|
{allUnits.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Text fz="11.5px" fw={600} c="#9AA8B5" mb={8}>
|
||||||
|
Container numbers
|
||||||
|
</Text>
|
||||||
|
<Table verticalSpacing="xs" horizontalSpacing={0}>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th style={{ color: "#9AA8B5", fontSize: 11 }}>Container no.</Table.Th>
|
||||||
|
<Table.Th style={{ color: "#9AA8B5", fontSize: 11 }}>Seal no.</Table.Th>
|
||||||
|
<Table.Th style={{ color: "#9AA8B5", fontSize: 11 }}>VGM</Table.Th>
|
||||||
|
<Table.Th style={{ color: "#9AA8B5", fontSize: 11 }}>Flags</Table.Th>
|
||||||
|
<Table.Th style={{ color: "#9AA8B5", fontSize: 11 }}>Port status</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{allUnits.map((u) => (
|
||||||
|
<UnitRow key={u.id} unit={u} />
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
mt="sm"
|
mt="sm"
|
||||||
pt="sm"
|
pt="sm"
|
||||||
@@ -78,7 +190,7 @@ export function ContainersCard({ booking }: { booking: Freight.IBooking }) {
|
|||||||
Total weight (VGM)
|
Total weight (VGM)
|
||||||
</Text>
|
</Text>
|
||||||
<Text fz={14} fw={800} c="#0A6F4D">
|
<Text fz={14} fw={800} c="#0A6F4D">
|
||||||
{totalVgm.toLocaleString()} t
|
{fmtWeight(totalVgm)}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Box miw={0} flex={1}>
|
||||||
|
<Text fz="11.5px" fw={600} c="#9AA8B5">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text mt={4} fz="14px" fw={700} c="#10202F">
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
gap={24}
|
||||||
|
align="flex-start"
|
||||||
|
wrap="nowrap"
|
||||||
|
py={13}
|
||||||
|
style={{ borderBottom: "1px solid #F2F5F8" }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<SectionCard>
|
||||||
|
<Group justify="space-between" align="center" mb={4}>
|
||||||
|
<CardTitle>Contract Information</CardTitle>
|
||||||
|
{booking.contractId && (
|
||||||
|
<Anchor
|
||||||
|
component={Link}
|
||||||
|
to={`/contracts/${booking.contractId}`}
|
||||||
|
fz="12.5px"
|
||||||
|
fw={700}
|
||||||
|
underline="hover"
|
||||||
|
>
|
||||||
|
View full contract →
|
||||||
|
</Anchor>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Row>
|
||||||
|
<Field
|
||||||
|
label="Contract reference"
|
||||||
|
value={
|
||||||
|
booking.contractId ? (
|
||||||
|
<Anchor
|
||||||
|
component={Link}
|
||||||
|
to={`/contracts/${booking.contractId}`}
|
||||||
|
fz="14px"
|
||||||
|
fw={700}
|
||||||
|
underline="hover"
|
||||||
|
>
|
||||||
|
{booking.contractReference ?? booking.reference}
|
||||||
|
</Anchor>
|
||||||
|
) : (
|
||||||
|
(booking.contractReference ?? booking.reference)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Contract type"
|
||||||
|
value={booking.contractType === "RENEWAL" ? "Renewal" : "New"}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Field
|
||||||
|
label="Order type"
|
||||||
|
value={isGeneralContract ? "General Contract" : "One-Time Booking"}
|
||||||
|
/>
|
||||||
|
<Field label="Payment currency" value={booking.paymentCurrency || "—"} />
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{hasValidity && (
|
||||||
|
<Row>
|
||||||
|
<Field label="Valid from" value={fmtDate(booking.contractValidFrom)} />
|
||||||
|
<Field
|
||||||
|
label={
|
||||||
|
booking.contractValidityDays
|
||||||
|
? `Valid until (${booking.contractValidityDays} days)`
|
||||||
|
: "Valid until"
|
||||||
|
}
|
||||||
|
value={fmtDate(booking.contractValidUntil)}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isGeneralContract && (booking.startDate || booking.endDate) && (
|
||||||
|
<Row>
|
||||||
|
<Field label="Service start" value={fmtDate(booking.startDate)} />
|
||||||
|
<Field label="Service end" value={fmtDate(booking.endDate)} />
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isGeneralContract && booking.expiresAt && (
|
||||||
|
<Row>
|
||||||
|
<Field label="Ordering window closes" value={fmtDate(booking.expiresAt)} />
|
||||||
|
<Field label="Version" value={`v${booking.versionNumber ?? 1}`} />
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{booking.customsClearingEnabled && (
|
||||||
|
<Row>
|
||||||
|
<Field label="Customs clearance" value="Enabled" />
|
||||||
|
<Field
|
||||||
|
label="Clearing agent"
|
||||||
|
value={booking.customsClearingAgent || "Assigned by Global Logistics"}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{booking.previousContractId && (
|
||||||
|
<Row>
|
||||||
|
<Field
|
||||||
|
label="Renewed from"
|
||||||
|
value={
|
||||||
|
<Anchor
|
||||||
|
component={Link}
|
||||||
|
to={`/contracts/${booking.previousContractId}`}
|
||||||
|
fz="14px"
|
||||||
|
fw={700}
|
||||||
|
underline="hover"
|
||||||
|
>
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
<Link2 size={13} />
|
||||||
|
Previous contract
|
||||||
|
</Group>
|
||||||
|
</Anchor>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Consolidation"
|
||||||
|
value={
|
||||||
|
booking.consolidationPartner
|
||||||
|
? `Paired with ${booking.consolidationPartner.reference}`
|
||||||
|
: booking.consolidationPartnerId
|
||||||
|
? "Paired"
|
||||||
|
: "Not consolidated"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{booking.financialTerms && (
|
||||||
|
<>
|
||||||
|
<Divider my={10} color="#F2F5F8" />
|
||||||
|
<Text fz="11.5px" fw={600} c="#9AA8B5" mb={6}>
|
||||||
|
Financial terms
|
||||||
|
</Text>
|
||||||
|
<Text fz="13px" c="#10202F" style={{ whiteSpace: "pre-wrap" }}>
|
||||||
|
{booking.financialTerms}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{booking.contractSummary && (
|
||||||
|
<>
|
||||||
|
<Divider my={10} color="#F2F5F8" />
|
||||||
|
<Group gap={6} align="center" mb={6}>
|
||||||
|
<FileText size={13} color="#9AA8B5" />
|
||||||
|
<Text fz="11.5px" fw={600} c="#9AA8B5">
|
||||||
|
Contract summary
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text fz="13px" c="#10202F" style={{ whiteSpace: "pre-wrap" }}>
|
||||||
|
{booking.contractSummary}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import { Box, Group, Text } from "@mantine/core";
|
import { Box, Group, Text } from "@mantine/core";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import type { Freight } from "@edr/types";
|
|
||||||
|
|
||||||
import { bookingStatusLabel } from "@/pages/bookings/booking-display";
|
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";
|
import { CardTitle, SectionCard } from "./layout";
|
||||||
|
|
||||||
type Row = { label: string; value: ReactNode; muted?: boolean };
|
type Row = { label: string; value: ReactNode; muted?: boolean };
|
||||||
@@ -50,14 +49,11 @@ export function ScheduleCard({
|
|||||||
title,
|
title,
|
||||||
consignment,
|
consignment,
|
||||||
}: {
|
}: {
|
||||||
booking: Freight.IBooking;
|
booking: BookingDetail;
|
||||||
title: string;
|
title: string;
|
||||||
consignment?: boolean;
|
consignment?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const service =
|
const service = serviceTypeLabel(booking);
|
||||||
booking.serviceType === "RAIL_AND_FORWARDING"
|
|
||||||
? "Rail + Forwarding"
|
|
||||||
: "Rail only";
|
|
||||||
const equipmentReturn =
|
const equipmentReturn =
|
||||||
booking.equipmentReturn === "WITH_RETURN" ? "With return" : "Without return";
|
booking.equipmentReturn === "WITH_RETURN" ? "With return" : "Without return";
|
||||||
const assignedTrain: Row = {
|
const assignedTrain: Row = {
|
||||||
|
|||||||
@@ -1,12 +1,45 @@
|
|||||||
import { Box, Group, Text } from "@mantine/core";
|
import { Box, Group, Text } from "@mantine/core";
|
||||||
import { FileText } from "lucide-react";
|
import { FileText } from "lucide-react";
|
||||||
|
|
||||||
import type { Freight } from "@edr/types";
|
import type { BookingDetail } from "../booking-detail-types";
|
||||||
|
import {
|
||||||
import { containerSummary, fmtDate, yardLabel } from "../utils";
|
commodityLabel,
|
||||||
|
containerSummary,
|
||||||
|
fmtDate,
|
||||||
|
fmtWeight,
|
||||||
|
serviceTypeLabel,
|
||||||
|
shippingLineLabel,
|
||||||
|
totalVgmTons,
|
||||||
|
yardLabel,
|
||||||
|
} from "../utils";
|
||||||
import { CardTitle, SectionCard } from "./layout";
|
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 (
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: palette.bg,
|
||||||
|
border: `1px solid ${palette.border}`,
|
||||||
|
padding: "3px 10px",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: palette.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShipmentDetailsCard({ booking }: { booking: BookingDetail }) {
|
||||||
|
const weight = totalVgmTons(booking);
|
||||||
const rows: [string, string][][] = [
|
const rows: [string, string][][] = [
|
||||||
[
|
[
|
||||||
["Origin yard", yardLabel(booking.originYard)],
|
["Origin yard", yardLabel(booking.originYard)],
|
||||||
@@ -14,22 +47,14 @@ export function ShipmentDetailsCard({ booking }: { booking: Freight.IBooking })
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
["Freight type", booking.freightType === "BULK" ? "Bulk" : "Container"],
|
["Freight type", booking.freightType === "BULK" ? "Bulk" : "Container"],
|
||||||
["Commodity", booking.freightSubtype || "—"],
|
["Commodity", commodityLabel(booking)],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
["Containers / load", containerSummary(booking)],
|
["Containers / load", containerSummary(booking)],
|
||||||
[
|
["Total weight (VGM)", fmtWeight(weight)],
|
||||||
"Total weight (VGM)",
|
|
||||||
booking.cargoTotalWeightVgm ? `${booking.cargoTotalWeightVgm} t` : "—",
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
[
|
["Service type", serviceTypeLabel(booking)],
|
||||||
"Service type",
|
|
||||||
booking.serviceType === "RAIL_AND_FORWARDING"
|
|
||||||
? "Rail + Forwarding"
|
|
||||||
: "Rail only",
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
"Equipment return",
|
"Equipment return",
|
||||||
booking.equipmentReturn === "WITH_RETURN"
|
booking.equipmentReturn === "WITH_RETURN"
|
||||||
@@ -44,32 +69,49 @@ export function ShipmentDetailsCard({ booking }: { booking: Freight.IBooking })
|
|||||||
],
|
],
|
||||||
["Scheduled date", fmtDate(booking.scheduledDate)],
|
["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 (
|
return (
|
||||||
<SectionCard>
|
<SectionCard>
|
||||||
<Group justify="space-between" align="center" pb={3}>
|
<Group justify="space-between" align="center" pb={3}>
|
||||||
<CardTitle>Shipment Details</CardTitle>
|
<CardTitle>Shipment Details</CardTitle>
|
||||||
<Group
|
<Group gap={6} wrap="wrap" justify="flex-end">
|
||||||
component="span"
|
{badges.map((b) => (
|
||||||
gap={6}
|
<Badge
|
||||||
align="center"
|
key={b}
|
||||||
wrap="nowrap"
|
label={b}
|
||||||
style={{
|
tone={b === "Customs clearance" ? "blue" : "amber"}
|
||||||
display: "inline-flex",
|
/>
|
||||||
borderRadius: 999,
|
))}
|
||||||
backgroundColor: "#F1F4F7",
|
<Group
|
||||||
padding: "5px 11px",
|
component="span"
|
||||||
fontSize: 11.5,
|
gap={6}
|
||||||
fontWeight: 700,
|
align="center"
|
||||||
color: "#475569",
|
wrap="nowrap"
|
||||||
}}
|
style={{
|
||||||
>
|
display: "inline-flex",
|
||||||
<FileText size={13} />
|
borderRadius: 999,
|
||||||
{booking.contractType === "RENEWAL"
|
backgroundColor: "#F1F4F7",
|
||||||
? "Renewal contract"
|
padding: "5px 11px",
|
||||||
: "New contract"}
|
fontSize: 11.5,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#475569",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileText size={13} />
|
||||||
|
{booking.contractType === "RENEWAL"
|
||||||
|
? "Renewal contract"
|
||||||
|
: "New contract"}
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
<Box>
|
<Box>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { format } from "date-fns";
|
|||||||
|
|
||||||
import type { Freight } from "@edr/types";
|
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 isNegative = (s: string) => s === "CANCELLED" || s === "REJECTED";
|
||||||
export const isDraftLike = (s: string) =>
|
export const isDraftLike = (s: string) =>
|
||||||
s === "DRAFT" || s === "CHANGES_REQUESTED";
|
s === "DRAFT" || s === "CHANGES_REQUESTED";
|
||||||
@@ -37,17 +39,105 @@ export function yardLabel(y?: Freight.IBooking["originYard"]) {
|
|||||||
return y?.label ?? y?.code ?? "—";
|
return y?.label ?? y?.code ?? "—";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function containerSummary(b: Freight.IBooking) {
|
function containerLineLabel(c: NonNullable<BookingDetail["bookingContainers"]>[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) {
|
if (b.containers?.length) {
|
||||||
return b.containers.map((c) => `${c.qty} × ${c.type}`).join(", ");
|
return b.containers.map((c) => `${c.qty} × ${c.type}`).join(", ");
|
||||||
}
|
}
|
||||||
return b.freightType === "BULK" ? "Bulk cargo" : "—";
|
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.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 load = containerSummary(b);
|
||||||
const route = `${yardLabel(b.originYard)} → ${yardLabel(b.destinationYard)}`;
|
const route = `${yardLabel(b.originYard)} → ${yardLabel(b.destinationYard)}`;
|
||||||
return [cargo, load, route].filter((p) => p && p !== "—").join(" · ");
|
return [cargo, load, route].filter((p) => p && p !== "—").join(" · ");
|
||||||
|
|||||||
Reference in New Issue
Block a user