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