mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 06:28:12 +00:00
feat: enhance booking management with shipping line support and cargo handling improvements
This commit is contained in:
@@ -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}`} />}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user