From 9f531147789ebcd5a5a9076a7b4bbd1f3b4ed832 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 15 Aug 2026 18:48:33 +0000 Subject: [PATCH] feat: enhance booking management with shipping line support and cargo handling improvements --- .../modules/auth/customer-reset.service.ts | 85 ++++++++++++------- .../modules/auth/forgot-password.service.ts | 4 +- .../modules/bookings/bookings.repository.ts | 7 ++ .../src/modules/bookings/bookings.service.ts | 36 ++++++++ .../bookings/dto/filter-booking.dto.ts | 8 ++ .../shipping-line-companies.service.ts | 28 +++--- .../bookings/detail/BookingCargoCard.tsx | 31 ++++++- .../features/bookings/mapBookingListRow.ts | 10 ++- .../bookings/BookingRequestDetailPage.tsx | 34 +++++++- .../pages/bookings/BookingRequestsPage.tsx | 19 ++++- .../src/services/bookings.service.ts | 4 + .../backoffice/src/types/booking.ts | 6 ++ .../backoffice/src/utils/cargoWeight.ts | 20 ++++- 13 files changed, 230 insertions(+), 62 deletions(-) diff --git a/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts b/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts index b00fd588e..0a7ed942e 100644 --- a/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts +++ b/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts @@ -1,7 +1,6 @@ import { Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { InjectRepository } from "@nestjs/typeorm"; -import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; import { Repository } from "typeorm"; import { ExternalProfile } from "../companies/entities/external-profile.entity"; @@ -11,9 +10,10 @@ import { ResetChannel } from "./dto/forgot-password.dto"; import { ForgotPasswordService, RESET_LINK_TTL_MS, + type ResetTicket, } from "./forgot-password.service"; 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. */ export interface CustomerResetTarget { @@ -116,6 +116,22 @@ export class CustomerResetService { channel: ResetChannel, options?: { scope?: string; allowWithoutCredential?: boolean }, ): Promise { + 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 { const user = options?.allowWithoutCredential ? await this.forgotPasswordService.resolveActivatableUserById(userId) : await this.forgotPasswordService.resolveActiveUserById(userId); @@ -128,51 +144,58 @@ export class CustomerResetService { : " (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. - * Callers have already resolved `user` to an active account. + * Shared tail: send the already-minted ticket to a resolved target → report. */ private async deliverResetLink( - user: User, + target: OtpTarget, userId: string, channel: ResetChannel, + ticket: ResetTicket, scope?: string, ): Promise { - 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 // 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 expiresAt = new Date(Date.now() + RESET_LINK_TTL_MS); -this.logger.log( - `Staff-triggered shipping line ${link}`, - ); const { queued } = target.email ? await this.emailClient.sendEmail({ diff --git a/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts b/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts index a2afdbbbc..673541747 100644 --- a/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts +++ b/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts @@ -212,7 +212,9 @@ export class ForgotPasswordService { * is the proof). */ async mintResetTicket(userId: string, ttlMs: number): Promise { - 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); await this.dataSource.transaction(async (manager) => { diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index c029e36d4..379449f4f 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -80,6 +80,8 @@ export interface BookingListFilterOptions { originYardId?: string; destinationYardId?: string; isGovernment?: 'true' | 'false'; + /** Shipping-line bookings vs ordinary customer bookings (exactly one owner is set). */ + customerKind?: 'SHIPPING_LINE' | 'CUSTOMER'; consolidationPaired?: string; } @@ -1043,6 +1045,11 @@ export class BookingsRepository extends BaseRepository { } else if (options.isGovernment === '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) { qb.andWhere('booking.trade_direction = :tradeDirection', { tradeDirection: options.tradeDirection, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index a9e9dfc34..0b5bf5a0a 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1766,6 +1766,38 @@ export class BookingsService { pending.has(b.id); } 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 { + 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, destinationYardId: filter.destinationYardId, isGovernment: filter.isGovernment, + customerKind: filter.customerKind, consolidationPaired: filter.consolidationPaired, // DTO carries 'true'/'false' strings (query params); the repo option is a // real boolean — convert, preserving "not filtered" when absent. @@ -2048,6 +2081,7 @@ export class BookingsService { originYardId: filter.originYardId, destinationYardId: filter.destinationYardId, isGovernment: filter.isGovernment, + customerKind: filter.customerKind, consolidationPaired: filter.consolidationPaired, }; @@ -2134,6 +2168,8 @@ export class BookingsService { { path: "booking" }, ); + await this.attachShippingLineCompanies([booking]); + if (booking.files && booking.files.length > 0) { booking.files = await Promise.all( booking.files.map(async (file: FileRecord) => { diff --git a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts index 43d489bac..b9cf58da0 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts @@ -111,6 +111,14 @@ export class FilterBookingDto { @IsIn(['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({ enum: ['true', 'false'], description: 'Filter customs vs self-clearance (non-customs) bookings', diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.service.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.service.ts index 16b2836e1..ad290def8 100644 --- a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.service.ts +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.service.ts @@ -132,37 +132,33 @@ export class ShippingLineCompaniesService { * 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 * addition when the number is domestic, since the gateway silently drops - * anything else (see `CustomerResetService`). Two links are two independent - * single-use tickets; whichever the line opens first works. + * anything else (see `CustomerResetService`). Both carry the SAME single-use + * 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. */ async sendActivationLink(shippingLine: ShippingLineCompany) { 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, - ResetChannel.Email, + channels, { scope, allowWithoutCredential: true }, ); + const emailed = sent.find((s) => s.channel === ResetChannel.Email) ?? null; if (!emailed) { this.logger.error( `Activation email not sent for shipping line ${shippingLine.id} — no reachable address`, ); } - - if (shippingLine.phoneNumber && isDomesticPhone(shippingLine.phoneNumber)) { - 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}`, - ); - } + if (channels.includes(ResetChannel.Phone) && !sent.some((s) => s.channel === ResetChannel.Phone)) { + this.logger.warn(`Activation SMS not sent for shipping line ${shippingLine.id}`); } return emailed; diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx index 1070aa050..dc7286b5c 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx @@ -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 ( + + + {cargoHeadline} + + + {isBulk ? "Bulk" : "Container"} + + {cargoDescription ? ( + + — {cargoDescription} + + ) : null} + {items != null && } diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts b/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts index d7ee13562..66f991989 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts @@ -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, diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index 37609ea40..62c26276a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -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={ - + {shippingLine ? ( + + + + {shippingLine.name} + + + Shipping line + + + ) : ( + + )} + {cargoLabel ? ( + + · {cargoLabel} + + ) : null} · Scheduled {booking.scheduledDate} diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index b41367619..0c6a3553a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -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() {

- + {b.isShippingLine ? ( + + ) : ( + + )} {b.customerLabel} + {b.isShippingLine ? ( + + Shipping line + + ) : null}

diff --git a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts index 6a86dba7d..dead9c520 100644 --- a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts @@ -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(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; diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index b4b154800..510139731 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -251,6 +251,10 @@ export interface BookingDetail { updatedAt: string; // customer?: BookingNamedRef & { companyName?: string }; company?: BookingNamedRef & Partial; + /** 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; diff --git a/apps/edr-freight-web/backoffice/src/utils/cargoWeight.ts b/apps/edr-freight-web/backoffice/src/utils/cargoWeight.ts index 7e90313dd..45056ace2 100644 --- a/apps/edr-freight-web/backoffice/src/utils/cargoWeight.ts +++ b/apps/edr-freight-web/backoffice/src/utils/cargoWeight.ts @@ -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 }; }