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,7 +1,6 @@
import { Injectable, Logger } from "@nestjs/common"; import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { InjectRepository } from "@nestjs/typeorm"; import { InjectRepository } from "@nestjs/typeorm";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { Repository } from "typeorm"; import { Repository } from "typeorm";
import { ExternalProfile } from "../companies/entities/external-profile.entity"; import { ExternalProfile } from "../companies/entities/external-profile.entity";
@@ -11,9 +10,10 @@ import { ResetChannel } from "./dto/forgot-password.dto";
import { import {
ForgotPasswordService, ForgotPasswordService,
RESET_LINK_TTL_MS, RESET_LINK_TTL_MS,
type ResetTicket,
} from "./forgot-password.service"; } from "./forgot-password.service";
import { maskOtpTarget } from "./mask-target.util"; import { maskOtpTarget } from "./mask-target.util";
import { isDomesticPhone } from "../otp/otp.service"; import { isDomesticPhone, type OtpTarget } from "../otp/otp.service";
/** The account a staff-triggered reset would land on. */ /** The account a staff-triggered reset would land on. */
export interface CustomerResetTarget { export interface CustomerResetTarget {
@@ -116,6 +116,22 @@ export class CustomerResetService {
channel: ResetChannel, channel: ResetChannel,
options?: { scope?: string; allowWithoutCredential?: boolean }, options?: { scope?: string; allowWithoutCredential?: boolean },
): Promise<SentResetLink | null> { ): Promise<SentResetLink | null> {
const sent = await this.sendResetLinkToUserOnChannels(userId, [channel], options);
return sent[0] ?? null;
}
/**
* One ticket, several channels. Minting retires every earlier ticket for the
* user (`mintResetTicket`), so sending email and SMS as two separate mints
* makes the first link dead on arrival — the same link must go to both.
* Returns one entry per channel that was actually sent (unreachable channels
* are skipped, not errors).
*/
async sendResetLinkToUserOnChannels(
userId: string,
channels: ResetChannel[],
options?: { scope?: string; allowWithoutCredential?: boolean },
): Promise<SentResetLink[]> {
const user = options?.allowWithoutCredential const user = options?.allowWithoutCredential
? await this.forgotPasswordService.resolveActivatableUserById(userId) ? await this.forgotPasswordService.resolveActivatableUserById(userId)
: await this.forgotPasswordService.resolveActiveUserById(userId); : await this.forgotPasswordService.resolveActiveUserById(userId);
@@ -128,51 +144,58 @@ export class CustomerResetService {
: " (or has no active credential — pass allowWithoutCredential for first-time activation)" : " (or has no active credential — pass allowWithoutCredential for first-time activation)"
}`, }`,
); );
return null; return [];
} }
return this.deliverResetLink(user, user.id, channel, options?.scope); // Mint once, before any send: a failed send leaves an unused ticket that
// simply expires, whereas sending a link before the ticket exists would
// hand the customer a URL that is dead on arrival.
let ticket: ResetTicket | null = null;
const sent: SentResetLink[] = [];
for (const channel of channels) {
const target = this.forgotPasswordService.targetFor(user, channel);
if (!target) continue;
// The gateway silently drops foreign numbers — treat like a missing phone
// rather than reporting "link sent" for a message that will never arrive.
// The backoffice disables the channel up front via `phoneIsDomestic`; this
// guards direct API calls.
if (channel === "phone" && target.phone && !isDomesticPhone(target.phone)) {
this.logger.warn(
`Staff reset via SMS refused for user ${userId} — non-domestic phone`,
);
continue;
}
ticket ??= await this.forgotPasswordService.mintResetTicket(
user.id,
RESET_LINK_TTL_MS,
);
const result = await this.deliverResetLink(
target,
user.id,
channel,
ticket,
options?.scope,
);
if (result) sent.push(result);
}
return sent;
} }
/** /**
* Shared tail: target selection → SMS reachability → mint → send → report. * Shared tail: send the already-minted ticket to a resolved target → report.
* Callers have already resolved `user` to an active account.
*/ */
private async deliverResetLink( private async deliverResetLink(
user: User, target: OtpTarget,
userId: string, userId: string,
channel: ResetChannel, channel: ResetChannel,
ticket: ResetTicket,
scope?: string, scope?: string,
): Promise<SentResetLink | null> { ): Promise<SentResetLink | null> {
this.logger.log(
`Staff-triggered shipping line ${"link"}`,
);
const target = this.forgotPasswordService.targetFor(user, channel);
if (!target) return null;
// A foreign number is unreachable by the domestic-only SMS gateway — treat // A foreign number is unreachable by the domestic-only SMS gateway — treat
// it like a missing phone rather than reporting "link sent" for a message // it like a missing phone rather than reporting "link sent" for a message
// that will never arrive. The backoffice disables the channel up front via
// `phoneIsDomestic`; this guards direct API calls.
if (channel === "phone" && target.phone && !isDomesticPhone(target.phone)) {
this.logger.warn(
`Staff reset via SMS refused for user ${userId} — non-domestic phone`,
);
return null;
}
// Mint first, send second: a failed send leaves an unused ticket that simply
// expires, whereas sending a link before the ticket exists would hand the
// customer a URL that is dead on arrival.
const ticket = await this.forgotPasswordService.mintResetTicket(
userId,
RESET_LINK_TTL_MS,
);
const link = this.buildResetLink(ticket.userId, ticket.verificationCode); const link = this.buildResetLink(ticket.userId, ticket.verificationCode);
const expiresAt = new Date(Date.now() + RESET_LINK_TTL_MS); const expiresAt = new Date(Date.now() + RESET_LINK_TTL_MS);
this.logger.log(
`Staff-triggered shipping line ${link}`,
);
const { queued } = target.email const { queued } = target.email
? await this.emailClient.sendEmail({ ? await this.emailClient.sendEmail({

View File

@@ -212,7 +212,9 @@ export class ForgotPasswordService {
* is the proof). * is the proof).
*/ */
async mintResetTicket(userId: string, ttlMs: number): Promise<ResetTicket> { async mintResetTicket(userId: string, ttlMs: number): Promise<ResetTicket> {
const code = randomBytes(24).toString("base64url"); // Hex, not base64url: the token rides in an SMS, and the GSM-7 alphabet has
// no "_" — gateways substitute a space and the link arrives broken.
const code = randomBytes(24).toString("hex");
const verificationCode = await hashPassword(code); const verificationCode = await hashPassword(code);
await this.dataSource.transaction(async (manager) => { await this.dataSource.transaction(async (manager) => {

View File

@@ -80,6 +80,8 @@ export interface BookingListFilterOptions {
originYardId?: string; originYardId?: string;
destinationYardId?: string; destinationYardId?: string;
isGovernment?: 'true' | 'false'; isGovernment?: 'true' | 'false';
/** Shipping-line bookings vs ordinary customer bookings (exactly one owner is set). */
customerKind?: 'SHIPPING_LINE' | 'CUSTOMER';
consolidationPaired?: string; consolidationPaired?: string;
} }
@@ -1043,6 +1045,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
} else if (options.isGovernment === 'false') { } else if (options.isGovernment === 'false') {
qb.andWhere('booking.is_government = FALSE'); qb.andWhere('booking.is_government = FALSE');
} }
if (options.customerKind === 'SHIPPING_LINE') {
qb.andWhere('booking.shipping_line_company_id IS NOT NULL');
} else if (options.customerKind === 'CUSTOMER') {
qb.andWhere('booking.shipping_line_company_id IS NULL');
}
if (omit !== 'tradeDirection' && options.tradeDirection) { if (omit !== 'tradeDirection' && options.tradeDirection) {
qb.andWhere('booking.trade_direction = :tradeDirection', { qb.andWhere('booking.trade_direction = :tradeDirection', {
tradeDirection: options.tradeDirection, tradeDirection: options.tradeDirection,

View File

@@ -1766,6 +1766,38 @@ export class BookingsService {
pending.has(b.id); pending.has(b.id);
} }
this.attachPaymentDrainEnds(bookings); this.attachPaymentDrainEnds(bookings);
await this.attachShippingLineCompanies(bookings);
}
/**
* Batched name lookup for shipping-line-owned bookings (`companyId` null,
* `shippingLineCompanyId` set). No relation on the entity — the shipping-line
* module sits above bookings — so a raw query keyed off the loaded ids fills
* `shippingLineCompany` the way `company` is filled for customers.
*/
private async attachShippingLineCompanies(bookings: Booking[]): Promise<void> {
const ids = [
...new Set(
bookings
.map((b) => b.shippingLineCompanyId)
.filter((id): id is string => id != null),
),
];
if (!ids.length) return;
const rows: Array<{ id: string; name: string; email: string | null; phoneNumber: string | null }> =
await this.dataSource.query(
`SELECT id, name, email, phone_number AS "phoneNumber"
FROM freight.shipping_line_companies
WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL`,
[ids],
);
const byId = new Map(rows.map((r) => [r.id, r]));
for (const b of bookings) {
const line = b.shippingLineCompanyId ? byId.get(b.shippingLineCompanyId) : undefined;
if (line) {
(b as Booking & { shippingLineCompany?: typeof line }).shippingLineCompany = line;
}
}
} }
/** /**
@@ -1822,6 +1854,7 @@ export class BookingsService {
originYardId: filter.originYardId, originYardId: filter.originYardId,
destinationYardId: filter.destinationYardId, destinationYardId: filter.destinationYardId,
isGovernment: filter.isGovernment, isGovernment: filter.isGovernment,
customerKind: filter.customerKind,
consolidationPaired: filter.consolidationPaired, consolidationPaired: filter.consolidationPaired,
// DTO carries 'true'/'false' strings (query params); the repo option is a // DTO carries 'true'/'false' strings (query params); the repo option is a
// real boolean — convert, preserving "not filtered" when absent. // real boolean — convert, preserving "not filtered" when absent.
@@ -2048,6 +2081,7 @@ export class BookingsService {
originYardId: filter.originYardId, originYardId: filter.originYardId,
destinationYardId: filter.destinationYardId, destinationYardId: filter.destinationYardId,
isGovernment: filter.isGovernment, isGovernment: filter.isGovernment,
customerKind: filter.customerKind,
consolidationPaired: filter.consolidationPaired, consolidationPaired: filter.consolidationPaired,
}; };
@@ -2134,6 +2168,8 @@ export class BookingsService {
{ path: "booking" }, { path: "booking" },
); );
await this.attachShippingLineCompanies([booking]);
if (booking.files && booking.files.length > 0) { if (booking.files && booking.files.length > 0) {
booking.files = await Promise.all( booking.files = await Promise.all(
booking.files.map(async (file: FileRecord) => { booking.files.map(async (file: FileRecord) => {

View File

@@ -111,6 +111,14 @@ export class FilterBookingDto {
@IsIn(['true', 'false']) @IsIn(['true', 'false'])
isGovernment?: 'true' | 'false'; isGovernment?: 'true' | 'false';
@ApiPropertyOptional({
enum: ['SHIPPING_LINE', 'CUSTOMER'],
description: 'Who booked: a shipping line (owned by shipping_line_company_id) or an ordinary customer company',
})
@IsOptional()
@IsIn(['SHIPPING_LINE', 'CUSTOMER'])
customerKind?: 'SHIPPING_LINE' | 'CUSTOMER';
@ApiPropertyOptional({ @ApiPropertyOptional({
enum: ['true', 'false'], enum: ['true', 'false'],
description: 'Filter customs vs self-clearance (non-customs) bookings', description: 'Filter customs vs self-clearance (non-customs) bookings',

View File

@@ -132,37 +132,33 @@ export class ShippingLineCompaniesService {
* Email always goes out — it is required at registration and is the only * Email always goes out — it is required at registration and is the only
* channel guaranteed to reach a foreign-registered line. SMS is sent in * channel guaranteed to reach a foreign-registered line. SMS is sent in
* addition when the number is domestic, since the gateway silently drops * addition when the number is domestic, since the gateway silently drops
* anything else (see `CustomerResetService`). Two links are two independent * anything else (see `CustomerResetService`). Both carry the SAME single-use
* single-use tickets; whichever the line opens first works. * ticket: minting retires earlier tickets, so two mints would kill the email
* link the moment the SMS went out.
* *
* Reports the email send, as that is the one that is always attempted. * Reports the email send, as that is the one that is always attempted.
*/ */
async sendActivationLink(shippingLine: ShippingLineCompany) { async sendActivationLink(shippingLine: ShippingLineCompany) {
const scope = `shipping line ${shippingLine.id}`; const scope = `shipping line ${shippingLine.id}`;
const channels = [ResetChannel.Email];
if (shippingLine.phoneNumber && isDomesticPhone(shippingLine.phoneNumber)) {
channels.push(ResetChannel.Phone);
}
const emailed = await this.customerResetService.sendResetLinkToUser( const sent = await this.customerResetService.sendResetLinkToUserOnChannels(
shippingLine.userId, shippingLine.userId,
ResetChannel.Email, channels,
{ scope, allowWithoutCredential: true }, { scope, allowWithoutCredential: true },
); );
const emailed = sent.find((s) => s.channel === ResetChannel.Email) ?? null;
if (!emailed) { if (!emailed) {
this.logger.error( this.logger.error(
`Activation email not sent for shipping line ${shippingLine.id} — no reachable address`, `Activation email not sent for shipping line ${shippingLine.id} — no reachable address`,
); );
} }
if (channels.includes(ResetChannel.Phone) && !sent.some((s) => s.channel === ResetChannel.Phone)) {
if (shippingLine.phoneNumber && isDomesticPhone(shippingLine.phoneNumber)) { this.logger.warn(`Activation SMS not sent for shipping line ${shippingLine.id}`);
const texted = await this.customerResetService.sendResetLinkToUser(
shippingLine.userId,
ResetChannel.Phone,
{ scope, allowWithoutCredential: true },
);
if (!texted) {
this.logger.warn(
`Activation SMS not sent for shipping line ${shippingLine.id}`,
);
}
} }
return emailed; return emailed;

View File

@@ -1,5 +1,5 @@
import { Package } from "lucide-react"; 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 type { BookingDetail } from "@/types/booking";
import { cargoTonsAndItems } from "@/utils/cargoWeight"; import { cargoTonsAndItems } from "@/utils/cargoWeight";
@@ -29,12 +29,37 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
Number(c.hazardousQuantity ?? 0) > 0 || Number(c.reeferQuantity ?? 0) > 0, 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 ( return (
<SectionCard icon={Package} title="Cargo specifications" accent="orange"> <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"> <SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
<MetricTile <MetricTile
label="Cargo type" label={isBulk ? "Commodity" : "Cargo type"}
value={booking.cargoType?.label ?? booking.freightType} value={
isBulk
? (booking.cargoType?.label ?? booking.cargoType?.name ?? "—")
: (cargoDescription ?? booking.freightType)
}
/> />
<MetricTile label="Total VGM" value={`${tons} tons`} /> <MetricTile label="Total VGM" value={`${tons} tons`} />
{items != null && <MetricTile label="Items" value={`${items}`} />} {items != null && <MetricTile label="Items" value={`${items}`} />}

View File

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

View File

@@ -13,12 +13,14 @@ import {
MoreHorizontal, MoreHorizontal,
Package, Package,
RefreshCw, RefreshCw,
Ship,
Truck, Truck,
Wallet, Wallet,
Weight, Weight,
} from "lucide-react"; } from "lucide-react";
import { import {
ActionIcon, ActionIcon,
Badge,
Box, Box,
Button, Button,
Center, Center,
@@ -174,7 +176,14 @@ export default function BookingRequestDetailPage() {
}; };
const company = booking.company; const company = booking.company;
const shippingLine = booking.shippingLineCompany ?? null;
const customerName = toBookingListRow(booking).customerLabel; 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 amount = Number(booking.totalAmount);
const containers = booking.bookingContainers ?? []; const containers = booking.bookingContainers ?? [];
@@ -237,10 +246,27 @@ export default function BookingRequestDetailPage() {
} }
subtitle={ subtitle={
<Group gap={6} wrap="wrap"> <Group gap={6} wrap="wrap">
<EntityLink {shippingLine ? (
to={company?.id ? `/dashboard/customers/${company.id}` : null} <Group gap={6} wrap="nowrap">
label={customerName ?? "—"} <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"> <Text size="sm" c="dimmed">
· Scheduled {booking.scheduledDate} · Scheduled {booking.scheduledDate}
</Text> </Text>

View File

@@ -18,6 +18,7 @@ import {
Package, Package,
Plus, Plus,
RefreshCw, RefreshCw,
Ship,
User, User,
} from "lucide-react"; } from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "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" }, { 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. */ /** Status options for the filter select — built from the shared status styles. */
const STATUS_OPTIONS = Object.entries(BOOKING_STATUS_STYLES).map( const STATUS_OPTIONS = Object.entries(BOOKING_STATUS_STYLES).map(
([value, { label }]) => ({ value, label }), ([value, { label }]) => ({ value, label }),
@@ -132,6 +139,7 @@ export default function BookingRequestsPage() {
// split), so a deep link can never land behind "More filters" unseen. // split), so a deep link can never land behind "More filters" unseen.
const bookingFilterDefs: FilterDef[] = useMemo( 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: "bookingType", label: "Kind", type: "enum", multiple: false, options: BOOKING_KIND_OPTIONS },
{ key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS }, { key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS },
{ {
@@ -301,8 +309,17 @@ export default function BookingRequestsPage() {
</Badge> </Badge>
</div> </div>
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground"> <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.customerLabel}
{b.isShippingLine ? (
<Badge variant="secondary" className="h-4 shrink-0 px-1 text-[9px] font-medium">
Shipping line
</Badge>
) : null}
</p> </p>
</div> </div>
</div> </div>

View File

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

View File

@@ -251,6 +251,10 @@ export interface BookingDetail {
updatedAt: string; updatedAt: string;
// customer?: BookingNamedRef & { companyName?: string }; // customer?: BookingNamedRef & { companyName?: string };
company?: BookingNamedRef & Partial<BookingCompany>; 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; originYard?: BookingNamedRef;
destinationYard?: BookingNamedRef; destinationYard?: BookingNamedRef;
serviceType?: BookingNamedRef & { code?: string; includesCustoms?: boolean; includesFirstMile?: boolean; includesLastMile?: boolean }; 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. */ /** Needed to link the reference to the contract's detail page. */
contractId?: string | null; contractId?: string | null;
customerLabel: string; customerLabel: string;
/** True when the booking is owned by a shipping line rather than a customer company. */
isShippingLine?: boolean;
status: BookingStatus; status: BookingStatus;
scheduledDate: string; scheduledDate: string;
totalAmount: number; totalAmount: number;

View File

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