feat: enhance booking management with shipping line support and cargo handling improvements

This commit is contained in:
Marshal
2026-08-15 18:48:33 +00:00
parent 156fa9d2e4
commit 9f53114778
13 changed files with 230 additions and 62 deletions

View File

@@ -1,5 +1,5 @@
import { Package } from "lucide-react";
import { SimpleGrid, Divider, Box, Table, Text, Badge } from "@mantine/core";
import { SimpleGrid, Divider, Box, Group, Table, Text, Badge } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { cargoTonsAndItems } from "@/utils/cargoWeight";
@@ -29,12 +29,37 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
Number(c.hazardousQuantity ?? 0) > 0 || Number(c.reeferQuantity ?? 0) > 0,
);
const isBulk = booking.freightType === "BULK";
// Bulk: the commodity itself (Wheat, Steel…) is the headline. Containers:
// the freight kind, with the shipper's own description alongside.
const cargoHeadline = isBulk
? (booking.cargoType?.label ?? booking.cargoType?.name ?? "Bulk cargo")
: "Containers";
const cargoDescription = booking.cargoFreeText?.trim() || null;
return (
<SectionCard icon={Package} title="Cargo specifications" accent="orange">
<Group gap="sm" align="center" mb="md" wrap="wrap">
<Text fw={800} fz={22} lh={1.1}>
{cargoHeadline}
</Text>
<Badge variant="light" color={isBulk ? "orange" : "blue"} radius="sm">
{isBulk ? "Bulk" : "Container"}
</Badge>
{cargoDescription ? (
<Text size="sm" c="dimmed">
{cargoDescription}
</Text>
) : null}
</Group>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
<MetricTile
label="Cargo type"
value={booking.cargoType?.label ?? booking.freightType}
label={isBulk ? "Commodity" : "Cargo type"}
value={
isBulk
? (booking.cargoType?.label ?? booking.cargoType?.name ?? "—")
: (cargoDescription ?? booking.freightType)
}
/>
<MetricTile label="Total VGM" value={`${tons} tons`} />
{items != null && <MetricTile label="Items" value={`${items}`} />}

View File

@@ -20,9 +20,13 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
reference: booking.reference,
contractReference: booking.contractReference ?? null,
contractId: booking.contractId ?? null,
customerLabel: booking.isGovernment
? (booking.governmentInstitution ?? "Government")
: labelFromRef(booking.company, booking.companyId ?? undefined),
// Shipping-line bookings have no customer company — the line IS the customer.
customerLabel: booking.shippingLineCompany
? booking.shippingLineCompany.name
: booking.isGovernment
? (booking.governmentInstitution ?? "Government")
: labelFromRef(booking.company, booking.companyId ?? undefined),
isShippingLine: Boolean(booking.shippingLineCompany ?? booking.shippingLineCompanyId),
// customerLabel: labelFromRef(booking.customer, booking.customerId),
status: booking.status,
scheduledDate: booking.scheduledDate,

View File

@@ -13,12 +13,14 @@ import {
MoreHorizontal,
Package,
RefreshCw,
Ship,
Truck,
Wallet,
Weight,
} from "lucide-react";
import {
ActionIcon,
Badge,
Box,
Button,
Center,
@@ -174,7 +176,14 @@ export default function BookingRequestDetailPage() {
};
const company = booking.company;
const shippingLine = booking.shippingLineCompany ?? null;
const customerName = toBookingListRow(booking).customerLabel;
// What is being shipped, in words: bulk → the commodity (Wheat, Steel…);
// containers → the shipper's own description when given.
const cargoLabel =
booking.freightType === "BULK"
? (booking.cargoType?.label ?? booking.cargoType?.name ?? null)
: (booking.cargoFreeText?.trim() || null);
const amount = Number(booking.totalAmount);
const containers = booking.bookingContainers ?? [];
@@ -237,10 +246,27 @@ export default function BookingRequestDetailPage() {
}
subtitle={
<Group gap={6} wrap="wrap">
<EntityLink
to={company?.id ? `/dashboard/customers/${company.id}` : null}
label={customerName ?? "—"}
/>
{shippingLine ? (
<Group gap={6} wrap="nowrap">
<Ship size={14} />
<Text size="sm" fw={600}>
{shippingLine.name}
</Text>
<Badge size="xs" radius="sm" variant="light" color="teal">
Shipping line
</Badge>
</Group>
) : (
<EntityLink
to={company?.id ? `/dashboard/customers/${company.id}` : null}
label={customerName ?? "—"}
/>
)}
{cargoLabel ? (
<Text size="sm" c="dimmed">
· {cargoLabel}
</Text>
) : null}
<Text size="sm" c="dimmed">
· Scheduled {booking.scheduledDate}
</Text>

View File

@@ -18,6 +18,7 @@ import {
Package,
Plus,
RefreshCw,
Ship,
User,
} from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react";
@@ -62,6 +63,12 @@ const BOOKING_KIND_OPTIONS: { value: BookingKind; label: string }[] = [
{ value: "GENERAL_CONTRACT", label: "General booking" },
];
/** Who booked: shipping lines own bookings via `shippingLineCompanyId`, not a customer company. */
const CUSTOMER_KIND_OPTIONS = [
{ value: "SHIPPING_LINE", label: "Shipping line" },
{ value: "CUSTOMER", label: "Customer" },
];
/** Status options for the filter select — built from the shared status styles. */
const STATUS_OPTIONS = Object.entries(BOOKING_STATUS_STYLES).map(
([value, { label }]) => ({ value, label }),
@@ -132,6 +139,7 @@ export default function BookingRequestsPage() {
// split), so a deep link can never land behind "More filters" unseen.
const bookingFilterDefs: FilterDef[] = useMemo(
() => [
{ key: "customerKind", label: "Booked by", type: "enum", multiple: false, options: CUSTOMER_KIND_OPTIONS },
{ key: "bookingType", label: "Kind", type: "enum", multiple: false, options: BOOKING_KIND_OPTIONS },
{ key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS },
{
@@ -301,8 +309,17 @@ export default function BookingRequestsPage() {
</Badge>
</div>
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
<User className="size-3 shrink-0 opacity-70" />
{b.isShippingLine ? (
<Ship className="size-3 shrink-0 opacity-70" />
) : (
<User className="size-3 shrink-0 opacity-70" />
)}
{b.customerLabel}
{b.isShippingLine ? (
<Badge variant="secondary" className="h-4 shrink-0 px-1 text-[9px] font-medium">
Shipping line
</Badge>
) : null}
</p>
</div>
</div>

View File

@@ -36,6 +36,8 @@ export interface BookingListFilter {
scheduledTo?: string;
originYardId?: string;
destinationYardId?: string;
/** SHIPPING_LINE = booked by a shipping line; CUSTOMER = ordinary customer company. */
customerKind?: "SHIPPING_LINE" | "CUSTOMER";
/** "true" = government bookings only, "false" = private only. */
isGovernment?: "true" | "false";
/** Free-text search: booking reference, customer name, contract reference (server-side). */
@@ -151,6 +153,7 @@ export const bookingsService = {
if (filter.originYardId) params.originYardId = filter.originYardId;
if (filter.destinationYardId) params.destinationYardId = filter.destinationYardId;
if (filter.isGovernment) params.isGovernment = filter.isGovernment;
if (filter.customerKind) params.customerKind = filter.customerKind;
}
const response = await client.get<BookingListSummary>(B.LIST_SUMMARY, {
params,
@@ -184,6 +187,7 @@ export const bookingsService = {
if (filter.originYardId) params.originYardId = filter.originYardId;
if (filter.destinationYardId) params.destinationYardId = filter.destinationYardId;
if (filter.isGovernment) params.isGovernment = filter.isGovernment;
if (filter.customerKind) params.customerKind = filter.customerKind;
if (filter.customsClearingEnabled)
params.customsClearingEnabled = filter.customsClearingEnabled;
if (filter.search) params.search = filter.search;

View File

@@ -251,6 +251,10 @@ export interface BookingDetail {
updatedAt: string;
// customer?: BookingNamedRef & { companyName?: string };
company?: BookingNamedRef & Partial<BookingCompany>;
/** Set when booked by a shipping line (then `companyId`/`company` are null). */
shippingLineCompanyId?: string | null;
/** Owner when booked by a shipping line (then `company` is absent). Hydrated server-side. */
shippingLineCompany?: { id: string; name: string; email?: string | null; phoneNumber?: string | null };
originYard?: BookingNamedRef;
destinationYard?: BookingNamedRef;
serviceType?: BookingNamedRef & { code?: string; includesCustoms?: boolean; includesFirstMile?: boolean; includesLastMile?: boolean };
@@ -273,6 +277,8 @@ export interface BookingListRow {
/** Needed to link the reference to the contract's detail page. */
contractId?: string | null;
customerLabel: string;
/** True when the booking is owned by a shipping line rather than a customer company. */
isShippingLine?: boolean;
status: BookingStatus;
scheduledDate: string;
totalAmount: number;

View File

@@ -1,18 +1,32 @@
/**
* Break-bulk (PER_ITEM) bulk bookings overload `cargoTotalWeightVgm` with the
* ITEM COUNT; their real tonnage lives in `bulkTotalWeightTons`. Every other
* booking stores tons in `cargoTotalWeightVgm` directly. Rendering the raw
* VGM column showed a 20-item / 100T booking as "20 tons".
* ITEM COUNT; their real tonnage lives in `bulkTotalWeightTons`. Container
* bookings never store a total at all — `cargoTotalWeightVgm` stays 0 and the
* weight lives per line (`quantity × vgmPerUnitTons`). Every other booking
* stores tons in `cargoTotalWeightVgm` directly. Rendering the raw VGM column
* showed a 20-item / 100T booking as "20 tons" and every container booking as
* "0 tons".
*/
export function cargoTonsAndItems(booking: {
freightType?: string | null;
cargoTotalWeightVgm?: number | string | null;
bulkTotalWeightTons?: number | string | null;
bookingContainers?: Array<{
quantity?: number | string | null;
vgmPerUnitTons?: number | string | null;
}> | null;
}): { tons: number; items: number | null } {
const bulkTons = Number(booking.bulkTotalWeightTons ?? 0);
const vgm = Number(booking.cargoTotalWeightVgm ?? 0);
if (booking.freightType === "BULK" && bulkTons > 0) {
return { tons: bulkTons, items: vgm > 0 ? vgm : null };
}
if (vgm <= 0 && booking.bookingContainers?.length) {
const lineTons = booking.bookingContainers.reduce(
(sum, c) => sum + Number(c.quantity ?? 0) * Number(c.vgmPerUnitTons ?? 0),
0,
);
return { tons: Math.round(lineTons * 1000) / 1000, items: null };
}
return { tons: vgm, items: null };
}