shipping line

This commit is contained in:
Marshal
2026-08-13 18:56:52 +00:00
parent 0e00a98ef3
commit b9ba830a09
48 changed files with 6766 additions and 639 deletions

View File

@@ -0,0 +1,78 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Makerchecker for manual actions on shipping-line credit invoices.
*
* A shipping-line credit invoice is normally settled by the CBE webhook. Two
* manual paths exist for finance: recording an offline payment (MARK_PAID)
* and voiding an invoice raised in error (CANCEL, which releases its credits
* back to the unbilled pool). Both erase or move real debt, so neither is a
* single-person action: one permission raises the request, a different
* permission — held by a chief, and never the requester themselves — approves
* or rejects it. Rows are never deleted; decided requests are the audit trail.
*
* One PENDING row per invoice at a time (partial unique index): a second
* request while one is undecided is a coordination failure, not a workflow.
*/
export class ShippingLineInvoiceApprovals3530000000000
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DO $$ BEGIN
CREATE TYPE freight.shipping_line_invoice_approvals_action_enum
AS ENUM ('MARK_PAID', 'CANCEL');
EXCEPTION WHEN duplicate_object THEN NULL; END $$
`);
await queryRunner.query(`
DO $$ BEGIN
CREATE TYPE freight.shipping_line_invoice_approvals_status_enum
AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
EXCEPTION WHEN duplicate_object THEN NULL; END $$
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.shipping_line_invoice_approvals (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
invoice_id uuid NOT NULL REFERENCES freight.invoices (id),
action freight.shipping_line_invoice_approvals_action_enum NOT NULL,
status freight.shipping_line_invoice_approvals_status_enum NOT NULL DEFAULT 'PENDING',
requested_by uuid NOT NULL,
reason varchar(500) NOT NULL,
payment_reference varchar(255),
decided_by uuid,
decided_at timestamptz,
decision_note varchar(500),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_sl_invoice_approvals_invoice_status
ON freight.shipping_line_invoice_approvals (invoice_id, status)
`);
// The workflow invariant, enforced where it cannot race: at most one
// undecided request per invoice.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_sl_invoice_approvals_one_pending
ON freight.shipping_line_invoice_approvals (invoice_id)
WHERE status = 'PENDING' AND deleted_at IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP TABLE IF EXISTS freight.shipping_line_invoice_approvals`,
);
await queryRunner.query(
`DROP TYPE IF EXISTS freight.shipping_line_invoice_approvals_status_enum`,
);
await queryRunner.query(
`DROP TYPE IF EXISTS freight.shipping_line_invoice_approvals_action_enum`,
);
}
}

View File

@@ -13,6 +13,9 @@ import { logCtx } from "@edr/api-common";
import { DataSource, EntityManager, In } from "typeorm";
import { Booking } from "../bookings/entities/booking.entity";
// Entity-only import (no module edge): portal reads resolve shipping-line
// payers straight off the table.
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
import { EimsConfig } from "../../config/eims.config";
import { CompaniesService } from "../companies/companies.service";
import { FilesService } from "../files/files.service";
@@ -574,23 +577,61 @@ export class BillingService {
});
}
/** Invoices for the signed-in customer; empty when they have no company. */
/**
* Resolve a shipping-line company from the signed-in user (null for ordinary
* customers). Queried straight off the entity rather than through
* ShippingLineCompaniesService — that module already imports billing, so a
* service edge back would deepen the forwardRef cycle for one lookup.
*/
private async resolveShippingLineCompanyId(
userId: string,
): Promise<string | null> {
const line = await this.dataSource
.getRepository(ShippingLineCompany)
.findOne({ where: { userId } });
return line?.id ?? null;
}
/**
* Invoices for the signed-in portal user; empty when they have no company.
* A payer is either a customer company or a shipping line (enforced by the
* DB's single-payer check), so the two lookups cannot both match.
*/
async findForUser(
userId: string,
filter: { source?: string; sourceId?: string } = {},
): Promise<Invoice[]> {
const companyId = await this.resolveCompanyId(userId);
return companyId ? this.findByCompany(companyId, filter) : [];
if (companyId) return this.findByCompany(companyId, filter);
const shippingLineCompanyId =
await this.resolveShippingLineCompanyId(userId);
if (!shippingLineCompanyId) return [];
return this.invoices.findAll({
where: {
shippingLineCompanyId,
...(filter.source ? { source: filter.source } : {}),
...(filter.sourceId ? { sourceId: filter.sourceId } : {}),
},
order: { createdAt: "DESC" },
});
}
/** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */
/** Payer-scoped invoice detail (+ lines); 404 when not owned by the user. */
async findByIdForUser(
id: string,
userId: string,
): Promise<Invoice & { lines: InvoiceLine[] }> {
const companyId = await this.resolveCompanyId(userId);
const invoice = await this.findById(id);
if (!companyId || invoice.companyId !== companyId) {
const ownedByCompany =
invoice.companyId != null &&
invoice.companyId === (await this.resolveCompanyId(userId));
const ownedByShippingLine =
!ownedByCompany &&
invoice.shippingLineCompanyId != null &&
invoice.shippingLineCompanyId ===
(await this.resolveShippingLineCompanyId(userId));
if (!ownedByCompany && !ownedByShippingLine) {
throw new NotFoundException(`Invoice ${id} not found`);
}
return invoice;

View File

@@ -11,6 +11,7 @@ import { Booking } from './entities/booking.entity';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { resolveCompanyNotifyContact } from '../notifications/resolve-company-phone.util';
import { resolveShippingLineNotifyTarget } from '../notifications/resolve-shipping-line-contact.util';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
/**
@@ -58,10 +59,16 @@ export class BookingLifecycleNotifierService {
// Both channels come from the same resolver: the company row's own columns
// are only half the story (see companyNotifyEmailExpr), and reading them off
// the loaded entity silently dropped every mail to a company whose address
// lives in `attributes`.
const { phone, email } = b.companyId
? await resolveCompanyNotifyContact(this.dataSource, b.companyId)
: { phone: null, email: null };
// lives in `attributes`. A shipping-line booking has NO company — its
// contact lives on the shipping_line_companies row itself.
const { phone, email } = b.shippingLineCompanyId
? await resolveShippingLineNotifyTarget(
this.dataSource,
b.shippingLineCompanyId,
)
: b.companyId
? await resolveCompanyNotifyContact(this.dataSource, b.companyId)
: { phone: null, email: null };
if (phone) {
try {
@@ -82,13 +89,44 @@ export class BookingLifecycleNotifierService {
}
}
/** Persist + push an in-app item to all portal users of the booking's company. */
/**
* Persist + push an in-app item to the booking's portal owner: every portal
* user of the company, or — for a shipping-line booking — the line's own
* account, deep-linked into the shipping-line app rather than the customer
* one (its routes live under /shipping-line/*).
*/
private inApp(
b: Booking,
title: string,
body: string,
overrides: Partial<NotifyInput> = {},
): void {
if (b.shippingLineCompanyId) {
void (async () => {
const { userId } = await resolveShippingLineNotifyTarget(
this.dataSource,
b.shippingLineCompanyId!,
);
if (!userId) return;
void this.inbox.notify({
recipients: { userIds: [userId] },
audience: NotificationAudience.PORTAL,
type: NotificationType.BOOKING_STATUS,
title,
body,
data: { bookingId: b.id, reference: b.reference },
...overrides,
// After the spread: overrides carry customer links — the bell must
// land a shipping line on ITS booking page.
link: `/shipping-line/bookings/${b.id}`,
});
})().catch((err) =>
this.logger.warn(
`shipping-line inApp failed for ${this.ref(b)}: ${(err as Error).message}`,
),
);
return;
}
if (!b.companyId) return; // government/unlinked bookings have no portal users
void this.inbox.notify({
recipients: { companyId: b.companyId },
@@ -176,13 +214,23 @@ export class BookingLifecycleNotifierService {
/** Document approval finalized → customer can proceed to request operation. */
clearanceReady(b: Booking): void {
const msg =
`Document approval for booking ${b.reference} is finalized. ` +
`You can now proceed to request operation from the portal.`;
// A shipping line's next move is BOOKING (cargo + shipment day), not the
// customer's operation-request step — say so, or the message points at a
// flow their portal does not have.
const msg = b.shippingLineCompanyId
? `Documents for booking ${b.reference} are approved. ` +
`You can now book your shipment — enter the cargo and shipment day from the portal.`
: `Document approval for booking ${b.reference} is finalized. ` +
`You can now proceed to request operation from the portal.`;
void this.notifyContact(b, msg, 'DOCUMENT APPROVAL FINALIZED');
this.inApp(b, 'Document approval finalized', msg, {
type: NotificationType.CLEARANCE_DECISION,
});
this.inApp(
b,
b.shippingLineCompanyId
? 'Documents approved — book your shipment'
: 'Document approval finalized',
msg,
{ type: NotificationType.CLEARANCE_DECISION },
);
}
/** Intercity documents approved → booking waits in the ride-along pool. */
@@ -234,11 +282,19 @@ export class BookingLifecycleNotifierService {
/** Operation accepted → invoice ready; await payment / booking window. */
operationAccepted(b: Booking): void {
const msg =
`Your operation request for booking ${b.reference} has been accepted. ` +
`An invoice has been prepared — watch for the payment window to secure your slot.`;
// No invoice and no pay window for a shipping line — the charge sits on
// its credit account and the booking boards its dedicated train directly.
const msg = b.shippingLineCompanyId
? `Your booking ${b.reference} has been accepted. The charge has been ` +
`recorded on your credit account and your shipment is being placed on its train.`
: `Your operation request for booking ${b.reference} has been accepted. ` +
`An invoice has been prepared — watch for the payment window to secure your slot.`;
void this.notifyContact(b, msg, 'OPERATION ACCEPTED');
this.inApp(b, 'Operation request accepted', msg);
this.inApp(
b,
b.shippingLineCompanyId ? 'Booking accepted' : 'Operation request accepted',
msg,
);
}
/**

View File

@@ -39,6 +39,8 @@ import { ClearanceWorkflowService } from '../contracts/clearance-workflow.servic
import { ContractDocPhase } from '@edr/types';
import { BookingInvoiceService } from "./booking-invoice.service";
// Type-only: the DI edge stays event-based to keep the module graph acyclic.
import type { ShippingLineBookingAcceptedPayload } from "../shipping-lines/shipping-line-credits.service";
@Injectable()
export class BookingTransitionService {
@@ -1012,7 +1014,14 @@ export class BookingTransitionService {
// The customer's train pick only exists for export rail; it rides the
// booking through the space checks below AND is persisted so the accept /
// reserve path locks onto that train (pickExportSchedule honors it).
const requestedId = isExportTrain ? (requestedTrainScheduleId ?? null) : null;
// Shipping-line completions (bypassDayPool) pick among the line's own
// dedicated trains — already validated by the caller, so the pick is
// persisted here the same way an export pick is. Customer import/domestic
// bookings still never carry one (the batch engine assigns their train).
const requestedId =
isExportTrain || opts?.bypassDayPool
? (requestedTrainScheduleId ?? null)
: null;
// Export rail rides the exact train the customer picked — never an
// auto-assigned one. Both portal flows (clearance + contract completion)
// surface a picker, so a missing id is an invalid submission, not a
@@ -1216,10 +1225,22 @@ export class BookingTransitionService {
// booking page correctly still showed it as not payable. The batch engine
// issues it in `reserve` (SELECTED_FOR_BATCH), which is where the pay window
// and the real deadline are created — matching the portal's `canPay` gate.
const invoice = await this.invoiceService.ensureInvoiceForBooking(booking);
this.logger.log(
`Generated draft invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id} — issued on batch selection`,
);
//
// Shipping-line bookings mint NO invoice at all: they have no company row
// to bill (the invoices FK requires one) and they pay on the credit ledger
// — the charge was recorded at completion, and Finance bills a batch of
// credits later through ShippingLineCreditsService.generateInvoice.
if (booking.shippingLineCompanyId) {
this.logger.log(
`Skipping invoice for shipping-line booking ${booking.reference}:${booking.id} — billed later from the credit ledger`,
);
} else {
const invoice =
await this.invoiceService.ensureInvoiceForBooking(booking);
this.logger.log(
`Generated draft invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id} — issued on batch selection`,
);
}
// TODO: road (truck) orders are an incomplete feature — they stop at the
// dead-end ROAD_DISPATCH_PENDING status below (no dispatch transition, no
// per-km pricing wired via roadKmPrice, no pay surface in the portal). They
@@ -1233,6 +1254,7 @@ export class BookingTransitionService {
lockedAt: booking.lockedAt ?? now,
} as never);
const roadFresh = await this.bookingsService.findById(booking.id);
this.emitShippingLineAccepted(roadFresh);
this.notifier.operationAccepted(roadFresh);
return roadFresh;
}
@@ -1269,11 +1291,44 @@ export class BookingTransitionService {
// batch runs after the window closes + staff document review, never at accept
// time. (Legacy pre-migration schedules with no window phase are still served
// by the periodic legacy fill.)
//
// EXCEPT shipping-line bookings: they pay later on the credit ledger, so
// no pay window exists to wait for — accept places them straight onto
// their company's dedicated train and its wagons. Non-fatal on purpose:
// the accept has committed; an allocation hiccup leaves the booking in
// the day pool for the batch engine / staff instead of failing the accept.
if (booking.shippingLineCompanyId) {
try {
await this.bookingBatchService.allocateShippingLineAccepted(booking.id);
} catch (err) {
this.logger.warn(
`Auto-allocation failed for shipping-line booking ${booking.reference}:${booking.id} — left in the day pool: ${(err as Error).message}`,
);
}
}
const trainFresh = await this.bookingsService.findById(booking.id);
this.emitShippingLineAccepted(trainFresh);
this.notifier.operationAccepted(trainFresh);
return trainFresh;
}
/**
* A shipping-line booking becomes debt at THIS moment — Operations accepted
* it — not at completion/pricing. Event, not a service call:
* ShippingLineCreditsService listens (`shipping_line_booking.accepted`), and
* importing its module here would close a module cycle. Emitted after the
* accept has fully committed (including the export-capacity path, which can
* still revert the status above), so a failed accept never creates debt.
*/
private emitShippingLineAccepted(booking: Booking): void {
if (!booking.shippingLineCompanyId) return;
this.events.emit("shipping_line_booking.accepted", {
bookingId: booking.id,
reference: booking.reference,
amount: Number(booking.totalAmount),
} satisfies ShippingLineBookingAcceptedPayload);
}
async enrichBookingResponse(booking: Booking): Promise<
Booking & {
latestChangeRequestNote?: string | null;

View File

@@ -0,0 +1,33 @@
import { DataSource } from "typeorm";
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
/**
* Notification target for a shipping-line booking.
*
* A shipping line is NOT a `companies` row: the company IS the account — one
* IAM user (`userId`), and the contact details live on the
* `shipping_line_companies` row itself. So the customer resolvers
* (external_profiles fan-out, company attributes email) never apply; this is
* the one lookup every shipping-line notification routes through.
*
* Entity-only import — safe from any module graph: notifiers already own a
* DataSource and need no service from the shipping-lines module.
*/
export async function resolveShippingLineNotifyTarget(
dataSource: DataSource,
shippingLineCompanyId: string,
): Promise<{
userId: string | null;
phone: string | null;
email: string | null;
}> {
const line = await dataSource
.getRepository(ShippingLineCompany)
.findOne({ where: { id: shippingLineCompanyId } });
return {
userId: line?.userId ?? null,
phone: line?.phoneNumber ?? null,
email: line?.email ?? null,
};
}

View File

@@ -2,6 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Transform, Type } from "class-transformer";
import {
IsArray,
IsBoolean,
IsDateString,
IsIn,
IsInt,
@@ -15,15 +16,61 @@ import {
import { PAYMENT_CURRENCIES } from "../../contracts/dto/create-contract.dto";
/**
* One physical container on a line — number, seal, VGM and its per-container
* handling switches. Same shape the customer shipment form submits.
*/
export class CompleteShippingLineContainerUnitDto {
@ApiProperty({ example: "MSCU1234567" })
@IsString()
containerNumber!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
sealNumber?: string;
@ApiProperty({ minimum: 0, description: "VGM of this container, tons." })
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
vgmTons!: number;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
isHazardous?: boolean;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
isReefer?: boolean;
}
/**
* One container line the shipping line ships — container type + count, the
* same shape the customer one-time form collects (no per-unit ISO numbers;
* those are captured downstream at yard operations, as for customers).
* same shape the customer one-time form collects. When `units` is sent (the
* full booking page), per-container numbers/seals/VGM and handling switches
* are persisted exactly like the customer shipment form; without it (legacy
* modal shape) the line-level counts stand alone.
*/
export class CompleteShippingLineContainerLineDto {
@ApiProperty({ format: "uuid", description: "Container type being shipped." })
@ApiPropertyOptional({
format: "uuid",
description:
"Container type being shipped. Optional when containerSize is sent — the server resolves the type from the size.",
})
@IsOptional()
@IsUUID()
containerTypeId!: string;
containerTypeId?: string;
@ApiPropertyOptional({
description:
'Container size, e.g. "20ft" | "40ft". The server maps it to the configured container type (reefer variant when the line carries reefer boxes) — so the client never needs the type catalog.',
})
@IsOptional()
@IsString()
containerSize?: string;
@ApiProperty({ minimum: 1 })
@IsInt()
@@ -51,6 +98,17 @@ export class CompleteShippingLineContainerLineDto {
@Min(0)
@Transform(({ value }) => Number(value))
reeferQuantity?: number;
@ApiPropertyOptional({
type: [CompleteShippingLineContainerUnitDto],
description:
"Per-container details. When present, the handling counts and VGM are derived from these rows.",
})
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => CompleteShippingLineContainerUnitDto)
units?: CompleteShippingLineContainerUnitDto[];
}
/**
@@ -66,6 +124,15 @@ export class CompleteShippingLineBookingDto {
@IsDateString()
scheduledDate!: string;
@ApiPropertyOptional({
format: "uuid",
description:
"Which of the line's dedicated trains this booking rides. Required when more than one departs on the chosen day; implicit with a single departure.",
})
@IsOptional()
@IsUUID()
trainScheduleId?: string;
@ApiPropertyOptional({ enum: PAYMENT_CURRENCIES })
@IsOptional()
@IsIn([...PAYMENT_CURRENCIES])
@@ -99,6 +166,26 @@ export class CompleteShippingLineBookingDto {
@Transform(({ value }) => Number(value))
cargoWeightTons?: number;
@ApiPropertyOptional({
minimum: 0,
description: "Bulk freight: hazardous portion of the cargo.",
})
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
bulkHazardousQuantity?: number;
@ApiPropertyOptional({
minimum: 0,
description: "Bulk freight: refrigerated portion of the cargo.",
})
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
bulkReeferQuantity?: number;
@ApiPropertyOptional({ description: "What the containers carry." })
@IsOptional()
@IsString()

View File

@@ -38,6 +38,40 @@ export class GenerateCreditInvoiceDto {
dueInDays?: number;
}
/** Finance's request for a manual action on a credit invoice (maker step). */
export class RequestInvoiceActionDto {
@ApiProperty({
description:
"Why the action is needed. Shown to the approver and kept for audit.",
example: "Paid by bank transfer, slip #TT-4491",
})
@IsString()
@MinLength(3)
@MaxLength(500)
reason!: string;
@ApiPropertyOptional({
description:
"Offline payment reference (bank slip / transfer number). MARK_PAID requests only.",
example: "TT-4491",
})
@IsOptional()
@IsString()
@MaxLength(255)
paymentReference?: string;
}
/** The decision on a pending request (approve and reject routes). */
export class DecideInvoiceActionDto {
@ApiPropertyOptional({
description: "Decision note. Required when rejecting.",
})
@IsOptional()
@IsString()
@MaxLength(500)
note?: string;
}
/** Write-off of a single unbilled credit. */
export class CancelCreditDto {
@ApiProperty({

View File

@@ -0,0 +1,82 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
import { Invoice } from "../../billing/entities/invoice.entity";
/** What finance asked to do to a shipping-line credit invoice. */
export enum ShippingLineInvoiceActionType {
/** Record a full offline settlement (paid outside the gateway). */
MarkPaid = "MARK_PAID",
/** Void the invoice; its credits return to the unbilled pool. */
Cancel = "CANCEL",
}
export enum ShippingLineInvoiceActionStatus {
Pending = "PENDING",
Approved = "APPROVED",
Rejected = "REJECTED",
}
/**
* Makerchecker for manual actions on shipping-line credit invoices.
*
* Marking an invoice paid by hand erases real debt, and cancelling one
* releases its credits back to the unbilled pool — either done unilaterally is
* a one-person fraud path. So finance REQUESTS the action (one permission)
* and a chief APPROVES or REJECTS it (a separate permission, different
* person). Every request is kept, decided or not: the table is the audit
* trail of who asked, who decided, and why.
*/
@Entity({ schema: "freight", name: "shipping_line_invoice_approvals" })
@Index(["invoiceId", "status"])
export class ShippingLineInvoiceApproval extends BaseEntity {
@Column({ name: "invoice_id", type: "uuid" })
invoiceId!: string;
@ManyToOne(() => Invoice)
@JoinColumn({ name: "invoice_id" })
invoice?: Invoice;
@Column({ name: "action", type: "enum", enum: ShippingLineInvoiceActionType })
action!: ShippingLineInvoiceActionType;
@Column({
name: "status",
type: "enum",
enum: ShippingLineInvoiceActionStatus,
default: ShippingLineInvoiceActionStatus.Pending,
})
status!: ShippingLineInvoiceActionStatus;
/** IAM user id of the finance staff who raised the request. */
@Column({ name: "requested_by", type: "uuid" })
requestedBy!: string;
/** Why the action is needed; shown to the approver, kept for audit. */
@Column({ name: "reason", type: "varchar", length: 500 })
reason!: string;
/** Offline payment reference (bank slip no. etc.) for MARK_PAID requests. */
@Column({
name: "payment_reference",
type: "varchar",
length: 255,
nullable: true,
})
paymentReference?: string | null;
/** IAM user id of the chief who approved/rejected; null while pending. */
@Column({ name: "decided_by", type: "uuid", nullable: true })
decidedBy?: string | null;
@Column({ name: "decided_at", type: "timestamptz", nullable: true })
decidedAt?: Date | null;
@Column({
name: "decision_note",
type: "varchar",
length: 500,
nullable: true,
})
decisionNote?: string | null;
}

View File

@@ -1,5 +1,13 @@
import { CurrentUser } from "@edr/api-common";
import { Body, Controller, Get, Param, ParseUUIDPipe, Post } from "@nestjs/common";
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Query,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { PortalCustomer } from "../../common/booking-guards";
@@ -37,6 +45,54 @@ export class ShippingLineBookingCompletionController {
return this.completionService.availableDaysMine(user.id, id);
}
@Get(":id/trains")
@PortalCustomer()
@ApiOperation({
summary:
"The line's dedicated trains on the booking's lane for a shipment day, each with per-wagon-type free space — for the completion form's train picker. Cargo context (sizes/cargoTypeId/wagons) refines the availability.",
})
async trainsForDay(
@CurrentUser() user: CurrentIamUser,
@Param("id", ParseUUIDPipe) id: string,
@Query("date") date?: string,
@Query("sizes") sizes?: string,
@Query("cargoTypeId") cargoTypeId?: string,
@Query("wagons") wagons?: string,
) {
return this.completionService.trainsForDayMine(user.id, id, date, {
containerSizes: sizes ? sizes.split(",").filter(Boolean) : undefined,
cargoTypeId: cargoTypeId || undefined,
wagons: wagons ? Number(wagons) : undefined,
});
}
@Post(":id/price-preview")
@PortalCustomer()
@ApiOperation({
summary:
"Authoritative price quote for the completion payload — same compute as /complete, saved as the booking's breakdown + rate snapshots (refreshed on every re-preview). Persists nothing else.",
})
async pricePreview(
@CurrentUser() user: CurrentIamUser,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: CompleteShippingLineBookingDto,
) {
return this.completionService.previewPriceMine(user.id, id, dto);
}
@Get(":id/operations")
@PortalCustomer()
@ApiOperation({
summary:
"Operations view of the booking: the train it rides (assigned or requested) and the wagons allocated to it.",
})
async operations(
@CurrentUser() user: CurrentIamUser,
@Param("id", ParseUUIDPipe) id: string,
) {
return this.completionService.operationsMine(user.id, id);
}
@Post(":id/complete")
@PortalCustomer()
@ApiOperation({

View File

@@ -11,12 +11,18 @@ import { BookingPricingService } from "../bookings/booking-pricing.service";
import { BookingTransitionService } from "../bookings/booking-transition.service";
import { BookingsService } from "../bookings/bookings.service";
import { BookingContainer } from "../bookings/entities/booking-container.entity";
import { BookingContainerUnit } from "../bookings/entities/booking-container-unit.entity";
import { Booking } from "../bookings/entities/booking.entity";
import { wagonsPerUnitForSize } from "../rule-engine/container-type.util";
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
import { ContainerType } from "../rule-engine/entities/container-type.entity";
import { eatDay } from "../train-scheduling/batch-window.util";
import {
BookingBatchService,
type TrainOptionCargoOverrides,
} from "../train-scheduling/booking-batch.service";
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
import { WagonBookingAllocation } from "../train-schedules/entities/wagon-booking-allocation.entity";
import { TrainSchedulingService } from "../train-scheduling/services/train-scheduling.service";
import { CompleteShippingLineBookingDto } from "./dto/complete-shipping-line-booking.dto";
import {
@@ -49,6 +55,7 @@ export class ShippingLineBookingCompletionService {
private readonly bookingPricingService: BookingPricingService,
private readonly bookingTransitionService: BookingTransitionService,
private readonly trainSchedulingService: TrainSchedulingService,
private readonly bookingBatchService: BookingBatchService,
private readonly creditsService: ShippingLineCreditsService,
) {}
@@ -108,6 +115,28 @@ export class ShippingLineBookingCompletionService {
return closesAt.getTime() > Date.now();
}
/**
* The line's dedicated trains on the booking's lane for one shipment day,
* each with per-wagon-type free space — the completion form's train picker.
* A booking rides ONE schedule, so with several departures that day the
* line picks which; the pick is validated again at complete time.
*/
async trainsForDayMine(
userId: string,
bookingId: string,
date: string | undefined,
overrides?: TrainOptionCargoOverrides,
) {
const booking = await this.requireOwnBooking(userId, bookingId);
if (!booking.shippingLineCompanyId) return [];
return this.bookingBatchService.dedicatedTrainOptionsForDay(
booking,
date ? eatDay(new Date(date)) : null,
booking.shippingLineCompanyId,
overrides,
);
}
/**
* Days the shipping line may pick as the shipment day.
*
@@ -174,12 +203,32 @@ export class ShippingLineBookingCompletionService {
(s) => eatDay(s.scheduledDepartureDate) === pickedDay,
);
let bypassDayPool = false;
let requestedTrainScheduleId: string | null = null;
if (dedicatedOnDay.length > 0) {
if (!dedicatedOnDay.some((s) => this.isStillOpen(s))) {
const openOnDay = dedicatedOnDay.filter((s) => this.isStillOpen(s));
if (openOnDay.length === 0) {
throw new BadRequestException(
"Booking for your train on this day has closed — the cut-off before departure has passed.",
);
}
// A booking rides ONE schedule. Several departures that day → the line
// must say which; a single one is picked implicitly. The id comes from
// the request, so it is validated against the day's own trains.
if (dto.trainScheduleId) {
const picked = openOnDay.find((s) => s.id === dto.trainScheduleId);
if (!picked) {
throw new BadRequestException(
"The selected train does not run your route on that day (or its booking cut-off has passed) — pick another train.",
);
}
requestedTrainScheduleId = picked.id;
} else if (openOnDay.length === 1) {
requestedTrainScheduleId = openOnDay[0].id;
} else {
throw new BadRequestException(
"More than one of your trains departs that day — select which train this booking rides.",
);
}
// The day is backed by the line's own train, which every customer pool
// deliberately excludes — so the day-pool gate downstream must not run.
bypassDayPool = true;
@@ -260,14 +309,35 @@ export class ShippingLineBookingCompletionService {
booking.freightType === "BULK" ? Number(dto.cargoWeightTons) : 0,
bulkTotalWeightTons:
booking.freightType === "BULK" ? Number(dto.cargoWeightTons) : null,
// Bulk handling portions — sized against the cargo, billed by pricing.
...(booking.freightType === "BULK"
? {
bulkHazardousQuantity: Number(dto.bulkHazardousQuantity ?? 0),
bulkReeferQuantity: Number(dto.bulkReeferQuantity ?? 0),
}
: {}),
// Hazard is per-line for containers; the booking-level flag is what
// pricing bills the surcharge from.
isHazardous: (dto.containers ?? []).some(
(line) => Number(line.hazardousQuantity ?? 0) > 0,
),
// Completion fixes the cargo — and therefore the price — so it is also
// where the billing currency is chosen.
paymentCurrency: dto.paymentCurrency ?? booking.paymentCurrency,
isHazardous:
(dto.containers ?? []).some(
(line) =>
Number(line.hazardousQuantity ?? 0) > 0 ||
(line.units ?? []).some((u) => u.isHazardous),
) || Number(dto.bulkHazardousQuantity ?? 0) > 0,
// Same for reefer: the rule engine's REEFER trigger fires on the
// booking-level flag (or a reefer container TYPE) — a ticked reefer
// switch on a standard box only sets the per-line count, so without
// this flag the surcharge silently never bills.
isReefer:
(dto.containers ?? []).some(
(line) =>
Number(line.reeferQuantity ?? 0) > 0 ||
(line.units ?? []).some((u) => u.isReefer),
) || Number(dto.bulkReeferQuantity ?? 0) > 0,
// Shipping lines are always billed in ETB: the charge lands on the
// ETB credit ledger, so the currency is enforced here rather than
// trusted from the payload.
paymentCurrency: "ETB",
} as never);
const loaded = await this.bookingsRepository.findOne({
@@ -305,14 +375,10 @@ export class ShippingLineBookingCompletionService {
computed.appliedModifiers,
);
// The charge goes on the line's credit ledger ("use now, pay later") —
// idempotent per booking, so a retried completion cannot double the debt.
await this.creditsService.recordCredit({
bookingId,
amount: computed.totalAmount,
currency: computed.currency,
description: `Freight service — booking ${booking.reference}`,
});
// No credit is recorded here: completion only REQUESTS the operation.
// The charge lands on the line's ledger when Operations accepts —
// `shipping_line_booking.accepted` → ShippingLineCreditsService — so a
// request that is returned or never accepted creates no debt.
}
// Binding day, OPERATION_REQUEST_PENDING and the staff notification — the
@@ -321,11 +387,307 @@ export class ShippingLineBookingCompletionService {
return this.bookingTransitionService.requestOperation(
bookingId,
dto.scheduledDate,
null,
requestedTrainScheduleId,
bypassDayPool ? { bypassDayPool: true } : undefined,
);
}
/**
* Authoritative price preview for the completion form's confirm step: the
* SAME compute the completion itself runs, over an in-memory probe shaped
* exactly like completeMine would persist the booking — so the figure the
* shipping line confirms is line-for-line what it will owe.
*
* The result is not advisory-only: the breakdown is saved on the booking and
* the rate snapshots are (re)written, so every re-preview refreshes them.
* Nothing else is persisted — no cargo rows, no credit, no transition.
*/
async previewPriceMine(
userId: string,
bookingId: string,
dto: CompleteShippingLineBookingDto,
) {
const booking = await this.requireOwnBooking(userId, bookingId, {
bookingContainers: true,
});
if (
!["CLEARANCE_READY", "OPERATION_CHANGES_REQUESTED"].includes(
booking.status,
)
) {
throw new BadRequestException(
"Your documents must be approved before the booking can be priced.",
);
}
// In-memory cargo, mirroring what completeMine persists.
let probeContainers: Partial<BookingContainer>[] = [];
let bulkFields: Record<string, unknown> = {};
if (booking.freightType === "CONTAINER") {
const lines = dto.containers ?? [];
if (!lines.length) {
throw new BadRequestException(
"At least one container line is required.",
);
}
for (const line of lines) {
const containerType = await this.resolveContainerType(line);
const figures = this.lineFigures(line);
probeContainers.push({
containerTypeId: containerType.id,
containerSize: containerType.sizeFt
? `${containerType.sizeFt}ft`
: null,
quantity: line.quantity,
hazardousQuantity: figures.hazardous,
reeferQuantity: figures.reefer,
returnQuantity: 0,
vgmPerUnitTons: figures.vgmPerUnit,
totalVgmTons: figures.totalVgm,
wagonsRequired: Math.ceil(
line.quantity * wagonsPerUnitForSize(containerType.sizeFt),
),
});
}
} else {
if (!dto.cargoTypeId || !(Number(dto.cargoWeightTons) > 0)) {
throw new BadRequestException(
"Bulk bookings need a cargo type and a total weight in tons.",
);
}
const cargoType = await this.bookingsRepository.manager
.getRepository(CargoType)
.findOne({ where: { id: dto.cargoTypeId, isActive: true } });
if (!cargoType) {
throw new NotFoundException(`Cargo type ${dto.cargoTypeId} not found`);
}
bulkFields = {
cargoTypeId: dto.cargoTypeId,
cargoTotalWeightVgm: Number(dto.cargoWeightTons),
bulkTotalWeightTons: Number(dto.cargoWeightTons),
bulkHazardousQuantity: Number(dto.bulkHazardousQuantity ?? 0),
bulkReeferQuantity: Number(dto.bulkReeferQuantity ?? 0),
};
probeContainers = [];
}
// Prototype-preserving clone so entity getters keep working — the same
// probe trick the contract preview uses.
const probe = Object.assign(
Object.create(Object.getPrototypeOf(booking)),
booking,
{
bookingContainers: probeContainers,
paymentCurrency: "ETB",
isHazardous:
(dto.containers ?? []).some(
(line) =>
Number(line.hazardousQuantity ?? 0) > 0 ||
(line.units ?? []).some((u) => u.isHazardous),
) || Number(dto.bulkHazardousQuantity ?? 0) > 0,
// Mirrors completeMine: without the booking-level flag the engine's
// REEFER trigger never fires for reefer opt-ins on standard boxes,
// and the quote would show base freight only.
isReefer:
(dto.containers ?? []).some(
(line) =>
Number(line.reeferQuantity ?? 0) > 0 ||
(line.units ?? []).some((u) => u.isReefer),
) || Number(dto.bulkReeferQuantity ?? 0) > 0,
...bulkFields,
},
) as Booking;
const computed =
await this.bookingPricingService.computePriceForBooking(probe);
if (!(computed.totalAmount > 0) || computed.hardBlocked.length > 0) {
throw new BadRequestException(
computed.hardBlocked.length > 0
? computed.hardBlocked.join("; ")
: "No rate is configured for your shipping line on this route/cargo — please contact Operations.",
);
}
// Persist the quoted figure: breakdown on the booking, snapshots of the
// rates it was built from. createPricingSnapshots clears the previous
// artifacts first, so a re-preview replaces the old quote rather than
// stacking a second one.
await this.bookingsRepository.update(bookingId, {
pricingBreakdown: {
lineItems: computed.lineItems,
totalAmount: computed.totalAmount,
currency: computed.currency,
generatedAt: new Date().toISOString(),
},
} as never);
await this.bookingPricingService.createPricingSnapshots(
bookingId,
computed.usedRates,
computed.appliedModifiers,
);
return {
totalAmount: computed.totalAmount,
currency: computed.currency,
lineItems: computed.lineItems,
warnings: computed.warnings,
};
}
/**
* What operations has done with the booking so far: the train it rides
* (assigned, or the requested one before assignment) and the wagons the
* batch engine allocated to it, with any container numbers loaded per wagon.
* Read-only, owner-scoped — feeds the detail page's Wagons & Train tab.
*/
async operationsMine(userId: string, bookingId: string) {
const booking = await this.requireOwnBooking(userId, bookingId);
const manager = this.bookingsRepository.manager;
const scheduleId =
booking.trainScheduleId ?? booking.requestedTrainScheduleId ?? null;
let train: Record<string, unknown> | null = null;
if (scheduleId) {
const schedule = await manager.getRepository(TrainSchedule).findOne({
where: { id: scheduleId },
relations: { originStation: true, destinationStation: true },
});
if (schedule) {
train = {
id: schedule.id,
reference: schedule.reference,
trainNumber: schedule.trainNumber,
status: schedule.status,
direction: schedule.direction,
scheduledDepartureDate: schedule.scheduledDepartureDate,
scheduledArrivalDate: schedule.scheduledArrivalDate,
originLabel:
schedule.originStation?.label ??
schedule.originStation?.code ??
"Origin",
destinationLabel:
schedule.destinationStation?.label ??
schedule.destinationStation?.code ??
"Destination",
// Whether this is the confirmed assignment or still the request.
assigned: Boolean(booking.trainScheduleId),
};
}
}
const allocations = await manager
.getRepository(WagonBookingAllocation)
.find({
where: { bookingId },
relations: {
trainSetWagon: { wagonType: true, physicalWagon: true },
containerItems: true,
},
order: { createdAt: "ASC" },
});
const wagons = allocations.map((allocation) => ({
id: allocation.id,
status: allocation.status,
loadType: allocation.loadType,
allocatedWeightTons: Number(allocation.allocatedWeightTons),
sequenceNo: allocation.trainSetWagon?.sequenceNo ?? null,
wagonNumber: allocation.trainSetWagon?.physicalWagon?.wagonNumber ?? null,
wagonType:
allocation.trainSetWagon?.wagonType?.name ??
allocation.trainSetWagon?.wagonType?.code ??
null,
capacityTons: Number(allocation.trainSetWagon?.capacityTons ?? 0),
containerNumbers: (allocation.containerItems ?? [])
.map((item) => item.containerNumber)
.filter((n): n is string => Boolean(n)),
}));
return { train, wagons };
}
/**
* Resolve a line's container type: by id when the payload carries one, else
* from the size string ("40ft" → the active 40ft type, preferring the reefer
* variant when the line ships reefer boxes). Resolution lives HERE, not in
* the portal, so a slow or failed catalog fetch can never block a booking
* with a phantom "type not configured" error — mirrors the customer flow's
* server-side size→type mapping.
*/
private async resolveContainerType(line: {
containerTypeId?: string;
containerSize?: string;
reeferQuantity?: number;
units?: { isReefer?: boolean }[];
}): Promise<ContainerType> {
const containerTypeRepo =
this.bookingsRepository.manager.getRepository(ContainerType);
if (line.containerTypeId) {
const byId = await containerTypeRepo.findOne({
where: { id: line.containerTypeId, isActive: true },
});
if (!byId) {
throw new NotFoundException(
`Container type ${line.containerTypeId} not found`,
);
}
return byId;
}
const sizeFt = parseInt(line.containerSize ?? "", 10);
if (!Number.isFinite(sizeFt)) {
throw new BadRequestException(
"Each container line needs a containerTypeId or a containerSize.",
);
}
const candidates = await containerTypeRepo.find({
where: { isActive: true },
});
const ofSize = candidates.filter((ct) => Number(ct.sizeFt) === sizeFt);
if (!ofSize.length) {
throw new BadRequestException(
`No ${sizeFt}ft container type is configured — please contact Operations.`,
);
}
const wantsReefer =
Number(line.reeferQuantity ?? 0) > 0 ||
(line.units ?? []).some((u) => u.isReefer);
if (wantsReefer) {
const reefer = ofSize.find((ct) => ct.isReefer);
if (reefer) return reefer;
}
return ofSize.find((ct) => !ct.isReefer) ?? ofSize[0];
}
/**
* A line's derived figures. With per-container rows (the full booking page),
* counts and VGM come FROM the rows — each container's switches are the
* source of truth. Without them, the line-level figures stand alone.
*/
private lineFigures(line: {
quantity: number;
vgmPerUnitTons?: number;
hazardousQuantity?: number;
reeferQuantity?: number;
units?: { vgmTons?: number; isHazardous?: boolean; isReefer?: boolean }[];
}) {
const units = line.units ?? [];
const hazardous = units.length
? units.filter((u) => u.isHazardous).length
: Math.min(Number(line.hazardousQuantity ?? 0), line.quantity);
const reefer = units.length
? units.filter((u) => u.isReefer).length
: Math.min(Number(line.reeferQuantity ?? 0), line.quantity);
const totalVgm = units.length
? units.reduce((s, u) => s + Number(u.vgmTons ?? 0), 0)
: Number(line.vgmPerUnitTons ?? 0) * line.quantity;
const vgmPerUnit = units.length
? totalVgm / units.length
: Number(line.vgmPerUnitTons ?? 0);
return { hazardous, reefer, totalVgm, vgmPerUnit };
}
/**
* Persist the container lines of a CONTAINER completion. Same row shape the
* customer paths write (quantity per type, VGM totals, wagon share) — the
@@ -341,27 +703,18 @@ export class ShippingLineBookingCompletionService {
throw new BadRequestException("At least one container line is required.");
}
const containerTypeRepo =
this.bookingsRepository.manager.getRepository(ContainerType);
const containerRepo =
this.bookingsRepository.manager.getRepository(BookingContainer);
const unitRepo =
this.bookingsRepository.manager.getRepository(BookingContainerUnit);
for (const line of lines) {
const containerType = await containerTypeRepo.findOne({
where: { id: line.containerTypeId, isActive: true },
});
if (!containerType) {
throw new NotFoundException(
`Container type ${line.containerTypeId} not found`,
);
}
const hazardous = Math.min(
Number(line.hazardousQuantity ?? 0),
line.quantity,
);
const reefer = Math.min(Number(line.reeferQuantity ?? 0), line.quantity);
const vgmPerUnit = Number(line.vgmPerUnitTons ?? 0);
await containerRepo.save(
const containerType = await this.resolveContainerType(line);
// Counts and VGM derived by lineFigures — the same math the price
// preview runs, so the persisted cargo always matches the quote.
const units = line.units ?? [];
const figures = this.lineFigures(line);
const containerRow = await containerRepo.save(
containerRepo.create({
bookingId: booking.id,
containerTypeId: containerType.id,
@@ -369,16 +722,31 @@ export class ShippingLineBookingCompletionService {
? `${containerType.sizeFt}ft`
: null,
quantity: line.quantity,
hazardousQuantity: hazardous,
reeferQuantity: reefer,
hazardousQuantity: figures.hazardous,
reeferQuantity: figures.reefer,
returnQuantity: 0,
vgmPerUnitTons: vgmPerUnit,
totalVgmTons: vgmPerUnit * line.quantity,
vgmPerUnitTons: figures.vgmPerUnit,
totalVgmTons: figures.totalVgm,
wagonsRequired: Math.ceil(
line.quantity * wagonsPerUnitForSize(containerType.sizeFt),
),
}),
);
let sortOrder = 0;
for (const unit of units) {
await unitRepo.save(
unitRepo.create({
bookingContainerId: containerRow.id,
containerNumber: unit.containerNumber.trim().toUpperCase(),
sealNumber: unit.sealNumber?.trim() || null,
vgmTons: Number(unit.vgmTons ?? 0),
isHazardous: unit.isHazardous ?? false,
isReefer: unit.isReefer ?? false,
isReturn: false,
sortOrder: sortOrder++,
}),
);
}
}
}

View File

@@ -319,8 +319,16 @@ export class ShippingLineBookingsService {
const booking = await this.bookingsRepository.findOne({
where: { id: bookingId, shippingLineCompanyId: shippingLine.id },
// Yards are loaded so the portal can render the lane without a second
// lookup — they are set at initiate time from the chosen route.
relations: { originYard: true, destinationYard: true, serviceType: true },
// lookup — they are set at initiate time from the chosen route. Cargo
// (container lines + units, bulk cargo type) rides along for the detail
// page's cargo tab once the booking is completed.
relations: {
originYard: true,
destinationYard: true,
serviceType: true,
cargoType: true,
bookingContainers: { containerType: true, units: true },
},
});
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
@@ -330,7 +338,24 @@ export class ShippingLineBookingsService {
.getRepository(BookingDocumentReview)
.count({ where: { bookingId, status: "QUERIED" } });
return { ...booking, hasQueriedDocuments: queriedCount > 0 };
// The note Operations wrote when returning the request — the line has to
// read it to know what to fix. Only the latest CHANGES_REQUESTED note is
// exposed; the other review-note types are staff-internal.
const changeNote =
booking.status === "OPERATION_CHANGES_REQUESTED"
? await this.bookingsRepository.manager
.getRepository(BookingReviewNote)
.findOne({
where: { bookingId, type: "CHANGES_REQUESTED" },
order: { createdAt: "DESC" },
})
: null;
return {
...booking,
hasQueriedDocuments: queriedCount > 0,
operationChangeNote: changeNote?.note ?? null,
};
}
/**

View File

@@ -49,7 +49,12 @@ export class ShippingLineCompaniesController {
}
@Get()
@BookingStaff(FREIGHT_PERMS.shippingLines.view)
// OR'd: the credits view needs this list as its line picker, so holding
// shipping_line_credits:view alone is enough to read it.
@BookingStaff([
FREIGHT_PERMS.shippingLines.view,
FREIGHT_PERMS.shippingLineCredits.view,
])
@ApiOperation({ summary: "List shipping lines (paginated)" })
async list(
@Query("page") page?: string,

View File

@@ -9,6 +9,8 @@ import { Booking } from "../bookings/entities/booking.entity";
import { OtpModule } from "../otp/otp.module";
import { ShippingLineCompany } from "./entities/shipping-line-company.entity";
import { ShippingLineCredit } from "./entities/shipping-line-credit.entity";
import { ShippingLineInvoiceApproval } from "./entities/shipping-line-invoice-approval.entity";
import { ShippingLineInvoiceApprovalsRepository } from "./shipping-line-invoice-approvals.repository";
import { ShippingLineBookingsController } from "./shipping-line-bookings.controller";
import { ShippingLineBookingsService } from "./shipping-line-bookings.service";
import { ShippingLineCompaniesController } from "./shipping-line-companies.controller";
@@ -25,6 +27,7 @@ import { ShippingLineCreditsService } from "./shipping-line-credits.service";
TypeOrmModule.forFeature([
ShippingLineCompany,
ShippingLineCredit,
ShippingLineInvoiceApproval,
User,
Booking,
]),
@@ -48,6 +51,7 @@ import { ShippingLineCreditsService } from "./shipping-line-credits.service";
ShippingLineBookingsService,
ShippingLineCreditsService,
ShippingLineCreditsRepository,
ShippingLineInvoiceApprovalsRepository,
],
// Exported so whatever prices a shipping-line booking can record the charge.
exports: [ShippingLineCompaniesService, ShippingLineCreditsService],

View File

@@ -1,4 +1,5 @@
import { CurrentUser } from "@edr/api-common";
import { Freight } from "@edr/types";
import {
Body,
Controller,
@@ -14,9 +15,12 @@ import { BookingStaff, PortalCustomer } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import {
CancelCreditDto,
DecideInvoiceActionDto,
GenerateCreditInvoiceDto,
RequestInvoiceActionDto,
} from "./dto/shipping-line-credit.dto";
import { ShippingLineCreditStatus } from "./entities/shipping-line-credit.entity";
import { ShippingLineInvoiceActionType } from "./entities/shipping-line-invoice-approval.entity";
import { ShippingLineCreditsService } from "./shipping-line-credits.service";
interface CurrentIamUser {
@@ -37,6 +41,159 @@ interface CurrentIamUser {
export class ShippingLineCreditsController {
constructor(private readonly credits: ShippingLineCreditsService) {}
@Get()
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.view)
@ApiOperation({
summary:
"The whole credit ledger across every shipping line (paginated), optionally filtered by line and/or status.",
})
async listAll(
@Query("page") page?: string,
@Query("pageSize") pageSize?: string,
@Query("status") status?: ShippingLineCreditStatus,
@Query("shippingLineId", new ParseUUIDPipe({ optional: true }))
shippingLineId?: string,
) {
return this.credits.listAll(
page ? Number(page) : 1,
pageSize ? Number(pageSize) : 20,
status,
shippingLineId,
);
}
// Declared before the parameterised staff routes so "summary" is never
// captured as a shipping-line id.
@Get("summary")
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.view)
@ApiOperation({
summary:
"Outstanding totals across every shipping line, or one line when shippingLineId is given.",
})
async summaryAll(
@Query("shippingLineId", new ParseUUIDPipe({ optional: true }))
shippingLineId?: string,
) {
return this.credits.summary(shippingLineId);
}
// Declared before ":shippingLineId" so "invoices" is never captured as an id.
@Get("invoices")
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.view)
@ApiOperation({
summary:
"Credit invoices across every shipping line (paginated), each with any pending manual-action request.",
})
async listInvoices(
@Query("page") page?: string,
@Query("pageSize") pageSize?: string,
@Query("status") status?: string,
@Query("shippingLineId", new ParseUUIDPipe({ optional: true }))
shippingLineId?: string,
) {
return this.credits.listCreditInvoices(
page ? Number(page) : 1,
pageSize ? Number(pageSize) : 20,
status as Freight.InvoiceStatus | undefined,
shippingLineId,
);
}
@Get("invoice-actions/pending")
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.view)
@ApiOperation({
summary:
"Undecided manual-action requests for a batch of invoices (one lookup for a list page).",
})
async pendingInvoiceActions(@Query("invoiceIds") invoiceIds?: string) {
const ids = (invoiceIds ?? "")
.split(",")
.map((id) => id.trim())
.filter(Boolean);
return this.credits.pendingInvoiceActions(ids);
}
// ── Makerchecker on credit invoices ──────────────────────────────────────
// Request and approve are DIFFERENT permissions, and the service refuses a
// decision by the requester — marking debt paid or voiding an invoice is
// never a one-person action.
@Post("invoices/:invoiceId/mark-paid-request")
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoiceMarkPaid)
@ApiOperation({
summary:
"Request recording a full offline payment against a credit invoice (awaits chief approval).",
})
async requestMarkPaid(
@Param("invoiceId", ParseUUIDPipe) invoiceId: string,
@Body() dto: RequestInvoiceActionDto,
@CurrentUser() user: CurrentIamUser,
) {
return this.credits.requestInvoiceAction(
invoiceId,
ShippingLineInvoiceActionType.MarkPaid,
user.id,
dto.reason,
dto.paymentReference,
);
}
@Post("invoices/:invoiceId/cancel-request")
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoiceCancel)
@ApiOperation({
summary:
"Request voiding a credit invoice — its credits return to the unbilled pool (awaits chief approval).",
})
async requestCancel(
@Param("invoiceId", ParseUUIDPipe) invoiceId: string,
@Body() dto: RequestInvoiceActionDto,
@CurrentUser() user: CurrentIamUser,
) {
return this.credits.requestInvoiceAction(
invoiceId,
ShippingLineInvoiceActionType.Cancel,
user.id,
dto.reason,
);
}
@Post("invoice-actions/:approvalId/approve")
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoiceApprove)
@ApiOperation({
summary:
"Approve a pending invoice request — executes the offline settlement or the cancellation.",
})
async approveInvoiceAction(
@Param("approvalId", ParseUUIDPipe) approvalId: string,
@Body() dto: DecideInvoiceActionDto,
@CurrentUser() user: CurrentIamUser,
) {
return this.credits.decideInvoiceAction(
approvalId,
user.id,
true,
dto.note,
);
}
@Post("invoice-actions/:approvalId/reject")
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoiceReject)
@ApiOperation({
summary: "Reject a pending invoice request — nothing is changed.",
})
async rejectInvoiceAction(
@Param("approvalId", ParseUUIDPipe) approvalId: string,
@Body() dto: DecideInvoiceActionDto,
@CurrentUser() user: CurrentIamUser,
) {
return this.credits.decideInvoiceAction(
approvalId,
user.id,
false,
dto.note,
);
}
// Declared before the parameterised staff routes so "me" is never captured
// as a shipping-line id.
@Get("me")

View File

@@ -76,25 +76,32 @@ export class ShippingLineCreditsRepository extends BaseRepository<ShippingLineCr
/**
* Derived debt — never a stored column. Grouped in one query so the detail
* page does not fan out per status.
* page does not fan out per status. Without a line id it totals every line —
* the back-office overview figure.
*/
async outstandingFor(
shippingLineCompanyId: string,
shippingLineCompanyId?: string,
): Promise<OutstandingTotals> {
const rows = await this.credits
const qb = this.credits
.createQueryBuilder("credit")
.select("credit.status", "status")
.addSelect("COALESCE(SUM(credit.amount), 0)", "amount")
.addSelect("COUNT(*)", "count")
.where("credit.shippingLineCompanyId = :shippingLineCompanyId", {
shippingLineCompanyId,
})
.andWhere("credit.status IN (:...statuses)", {
.where("credit.status IN (:...statuses)", {
statuses: [...OUTSTANDING_CREDIT_STATUSES],
})
.andWhere("credit.deletedAt IS NULL")
.groupBy("credit.status")
.getRawMany<{ status: string; amount: string; count: string }>();
.groupBy("credit.status");
if (shippingLineCompanyId) {
qb.andWhere("credit.shippingLineCompanyId = :shippingLineCompanyId", {
shippingLineCompanyId,
});
}
const rows = await qb.getRawMany<{
status: string;
amount: string;
count: string;
}>();
const totals = (status: ShippingLineCreditStatus) => {
const row = rows.find((r) => r.status === status);
@@ -117,19 +124,22 @@ export class ShippingLineCreditsRepository extends BaseRepository<ShippingLineCr
};
}
/** Paginated ledger for one line — every credit, whatever its status. */
/**
* Paginated ledger — every credit, whatever its status. Scoped to one line
* when an id is given, across all lines otherwise.
*/
findAllPaginated(
shippingLineCompanyId: string,
shippingLineCompanyId: string | undefined,
skip: number,
take: number,
status?: ShippingLineCreditStatus,
): Promise<[ShippingLineCredit[], number]> {
return this.credits.findAndCount({
where: {
shippingLineCompanyId,
...(shippingLineCompanyId ? { shippingLineCompanyId } : {}),
...(status ? { status } : {}),
},
relations: { booking: true, invoice: true },
relations: { booking: true, invoice: true, shippingLineCompany: true },
order: { createdAt: "DESC" },
skip,
take,

View File

@@ -77,6 +77,14 @@ describe("ShippingLineCreditsService", () => {
service = new ShippingLineCreditsService(
dataSource as never,
creditsRepo as never,
// Approvals repo — only the invoice makerchecker paths touch it.
{
findPendingByInvoice: jest.fn(),
findPendingByInvoiceIds: jest.fn().mockResolvedValue([]),
findByIdForUpdate: jest.fn(),
create: jest.fn(),
update: jest.fn(),
} as never,
billing as never,
shippingLines as never,
);

View File

@@ -21,7 +21,14 @@ import {
ShippingLineCredit,
ShippingLineCreditStatus,
} from "./entities/shipping-line-credit.entity";
import {
ShippingLineInvoiceApproval,
ShippingLineInvoiceActionStatus,
ShippingLineInvoiceActionType,
} from "./entities/shipping-line-invoice-approval.entity";
import { ShippingLineCreditsRepository } from "./shipping-line-credits.repository";
import { ShippingLineInvoiceApprovalsRepository } from "./shipping-line-invoice-approvals.repository";
import { ShippingLineCompany } from "./entities/shipping-line-company.entity";
import { ShippingLineCompaniesService } from "./shipping-line-companies.service";
/** A charge to record against a shipping line's booking. */
@@ -39,6 +46,18 @@ export interface GenerateCreditInvoiceOptions {
dueInDays?: number;
}
/**
* Emitted by the booking-transition accept path for shipping-line bookings.
* An event rather than a service call: BookingsModule cannot import the
* shipping-line modules without closing a module cycle.
*/
export interface ShippingLineBookingAcceptedPayload {
bookingId: string;
reference: string;
/** The booking's priced total, frozen at completion time. */
amount: number;
}
/**
* The credit ledger for shipping lines — "use the service now, pay later".
*
@@ -66,6 +85,7 @@ export class ShippingLineCreditsService {
constructor(
private readonly dataSource: DataSource,
private readonly credits: ShippingLineCreditsRepository,
private readonly approvals: ShippingLineInvoiceApprovalsRepository,
private readonly billing: BillingService,
private readonly shippingLines: ShippingLineCompaniesService,
) {}
@@ -144,6 +164,32 @@ export class ShippingLineCreditsService {
return manager ? run(manager) : this.dataSource.transaction(run);
}
/**
* The moment a shipping-line booking becomes debt: Operations accepted it.
* Swallows its own failures with a loud log instead of throwing — the accept
* has already committed, and failing the staff response for a ledger write
* would present a succeeded accept as an error. `recordCredit` is idempotent
* per booking, so a re-accepted (previously reverted) booking cannot double
* the debt.
*/
@OnEvent("shipping_line_booking.accepted")
async onBookingAccepted(
payload: ShippingLineBookingAcceptedPayload,
): Promise<void> {
try {
await this.recordCredit({
bookingId: payload.bookingId,
amount: payload.amount,
// Shipping lines are always billed in ETB (enforced at completion).
currency: "ETB",
});
} catch (err) {
this.logger.error(
`Failed to record credit for accepted shipping-line booking ${payload.reference} (${payload.bookingId}): ${(err as Error).message} — the debt is NOT on the ledger; record it manually or re-trigger.`,
);
}
}
// ── 2. Bill ────────────────────────────────────────────────────────────────
/**
@@ -326,6 +372,39 @@ export class ShippingLineCreditsService {
return this.credits.outstandingFor(shippingLineCompanyId);
}
/**
* Back-office overview: outstanding totals across every line, or one line
* when an id is given.
*/
async summary(shippingLineCompanyId?: string) {
if (shippingLineCompanyId) {
await this.requireShippingLine(shippingLineCompanyId);
}
return this.credits.outstandingFor(shippingLineCompanyId);
}
/**
* The whole ledger across every shipping line, newest first — finance's
* landing list. Optionally narrowed to one line and/or one status.
*/
async listAll(
page = 1,
pageSize = 20,
status?: ShippingLineCreditStatus,
shippingLineCompanyId?: string,
) {
if (shippingLineCompanyId) {
await this.requireShippingLine(shippingLineCompanyId);
}
const [items, total] = await this.credits.findAllPaginated(
shippingLineCompanyId,
(page - 1) * pageSize,
pageSize,
status,
);
return { items, total, page, pageSize };
}
/** Full ledger for one line, newest first. */
async listCredits(
shippingLineCompanyId: string,
@@ -369,6 +448,287 @@ export class ShippingLineCreditsService {
};
}
// ── Credit invoices: list + makerchecker manual actions ─────────────────
/**
* Staff list of the invoices minted from credit batches, each with its line
* name and any undecided manual-action request attached — the data the
* back-office actions column renders from.
*/
async listCreditInvoices(
page = 1,
pageSize = 20,
status?: Freight.InvoiceStatus,
shippingLineCompanyId?: string,
) {
const [invoices, total] = await this.dataSource
.getRepository(Invoice)
.findAndCount({
where: {
source: Freight.InvoiceSource.ShippingLineCredit,
...(status ? { status } : {}),
...(shippingLineCompanyId ? { shippingLineCompanyId } : {}),
},
order: { createdAt: "DESC" },
skip: (page - 1) * pageSize,
take: pageSize,
});
const lineIds = [
...new Set(
invoices
.map((inv) => inv.shippingLineCompanyId)
.filter((id): id is string => !!id),
),
];
const lines = lineIds.length
? await this.dataSource
.getRepository(ShippingLineCompany)
.find({ where: { id: In(lineIds) } })
: [];
const nameById = new Map(lines.map((l) => [l.id, l.name]));
const pending = await this.approvals.findPendingByInvoiceIds(
invoices.map((inv) => inv.id),
);
const pendingByInvoice = new Map(pending.map((p) => [p.invoiceId, p]));
return {
items: invoices.map((inv) => ({
...inv,
shippingLineName: inv.shippingLineCompanyId
? (nameById.get(inv.shippingLineCompanyId) ?? null)
: null,
pendingAction: pendingByInvoice.get(inv.id) ?? null,
})),
total,
page,
pageSize,
};
}
/** Undecided requests for a batch of invoices — feeds any invoice list. */
async pendingInvoiceActions(
invoiceIds: string[],
): Promise<ShippingLineInvoiceApproval[]> {
// Bounded to a list page's worth of ids; anything larger is a misuse.
return this.approvals.findPendingByInvoiceIds(invoiceIds.slice(0, 100));
}
/**
* Finance raises a manual action on a credit invoice: record an offline
* payment (MARK_PAID) or void it (CANCEL). Nothing happens to the invoice
* yet — a chief with the matching approve permission decides it. One
* undecided request per invoice (backed by a partial unique index).
*/
async requestInvoiceAction(
invoiceId: string,
action: ShippingLineInvoiceActionType,
requestedBy: string,
reason: string,
paymentReference?: string,
): Promise<ShippingLineInvoiceApproval> {
const invoice = await this.dataSource
.getRepository(Invoice)
.findOne({ where: { id: invoiceId } });
if (!invoice) {
throw new NotFoundException(`Invoice ${invoiceId} not found`);
}
if (invoice.source !== Freight.InvoiceSource.ShippingLineCredit) {
throw new BadRequestException(
"Manual actions here apply only to shipping-line credit invoices.",
);
}
// Fast feedback only — the billing service re-validates authoritatively
// (under lock) when the request is approved.
if (
action === ShippingLineInvoiceActionType.MarkPaid &&
invoice.status === Freight.InvoiceStatus.Paid
) {
throw new BadRequestException("Invoice is already paid.");
}
if (invoice.status === Freight.InvoiceStatus.Cancelled) {
throw new BadRequestException("Invoice is already cancelled.");
}
if (
action === ShippingLineInvoiceActionType.Cancel &&
Number(invoice.paidAmount) > 0
) {
throw new BadRequestException(
"Cannot cancel an invoice that has payments recorded against it.",
);
}
const existing = await this.approvals.findPendingByInvoice(invoiceId);
if (existing) {
throw new BadRequestException(
`A ${existing.action} request is already awaiting decision on this invoice.`,
);
}
const approval = await this.approvals.create({
invoiceId,
action,
status: ShippingLineInvoiceActionStatus.Pending,
requestedBy,
reason,
paymentReference: paymentReference ?? null,
});
logCtx(
{
approvalId: approval.id,
invoiceId,
invoiceNumber: invoice.invoiceNumber,
action,
requestedBy,
},
{ path: "shippingLineCredit.invoiceAction.requested" },
);
return approval;
}
/**
* Decide a pending request. Gated purely by permission (the approve/reject
* grants on the controller routes) — a decider holding the grant may decide
* ANY pending request, their own included; that trade-off is deliberate.
*
* Approval executes the real action through the billing service AFTER the
* decision row commits — its settlement/cancellation events must fire from
* billing's own committed transaction (the credits listeners react to
* them). If billing then rejects the action, the decision is compensated
* back to PENDING so the request is not silently lost.
*/
async decideInvoiceAction(
approvalId: string,
decidedBy: string,
approve: boolean,
note?: string,
): Promise<ShippingLineInvoiceApproval> {
if (!approve && !note?.trim()) {
throw new BadRequestException(
"A note is required when rejecting a request.",
);
}
const decided = await this.dataSource.transaction(async (mg) => {
const approval = await this.approvals.findByIdForUpdate(mg, approvalId);
if (!approval) {
throw new NotFoundException(`Request ${approvalId} not found`);
}
if (approval.status !== ShippingLineInvoiceActionStatus.Pending) {
throw new BadRequestException(
`This request was already ${approval.status.toLowerCase()}.`,
);
}
const status = approve
? ShippingLineInvoiceActionStatus.Approved
: ShippingLineInvoiceActionStatus.Rejected;
await mg.update(
ShippingLineInvoiceApproval,
{ id: approvalId },
{
status,
decidedBy,
decidedAt: new Date(),
decisionNote: note ?? null,
},
);
return { ...approval, status, decidedBy, decisionNote: note ?? null };
});
if (!approve) {
logCtx(
{ approvalId, invoiceId: decided.invoiceId, decidedBy },
{ path: "shippingLineCredit.invoiceAction.rejected" },
);
return decided;
}
try {
if (decided.action === ShippingLineInvoiceActionType.MarkPaid) {
const invoice = await this.dataSource
.getRepository(Invoice)
.findOne({ where: { id: decided.invoiceId } });
if (!invoice) {
throw new NotFoundException(`Invoice ${decided.invoiceId} not found`);
}
// Full settlement of the outstanding balance; billing emits
// `shipping_line_credit.invoice.paid`, which marks the credits PAID.
await this.billing.recordPayment(decided.invoiceId, {
amount: Number(invoice.balanceAmount ?? invoice.totalAmount),
method: "OFFLINE",
reference: decided.paymentReference ?? undefined,
metadata: {
approvalId: decided.id,
requestedBy: decided.requestedBy,
approvedBy: decidedBy,
},
});
} else {
// Billing emits `shipping_line_credit.invoice.cancelled`;
// onInvoiceCancelled releases the credits back to the unbilled pool.
await this.billing.cancelInvoice(decided.invoiceId);
}
} catch (err) {
// The action was refused (state changed since the request — e.g. the
// line paid through CBE in the meantime). Put the request back so it is
// not recorded as approved-but-unexecuted.
await this.approvals.update(approvalId, {
status: ShippingLineInvoiceActionStatus.Pending,
decidedBy: null,
decidedAt: null,
decisionNote: null,
});
throw err;
}
logCtx(
{
approvalId,
invoiceId: decided.invoiceId,
action: decided.action,
decidedBy,
},
{ path: "shippingLineCredit.invoiceAction.approved" },
);
return decided;
}
/**
* When a credit invoice is cancelled — through the approval flow or any
* other billing path — its BILLED credits return to the unbilled pool so
* the debt can be re-billed. The debt itself never disappears on invoice
* cancellation; only {@link cancelCredit} writes debt off.
*/
@OnEvent("shipping_line_credit.invoice.cancelled")
async onInvoiceCancelled(payload: InvoiceEventPayload): Promise<void> {
const result = await this.dataSource
.getRepository(ShippingLineCredit)
.update(
{
invoiceId: payload.invoiceId,
status: ShippingLineCreditStatus.Billed,
},
{
status: ShippingLineCreditStatus.Unbilled,
invoiceId: null,
billedAt: null,
},
);
logCtx(
{
invoiceId: payload.invoiceId,
invoiceNumber: payload.invoiceNumber,
creditsReleased: result.affected ?? 0,
},
{ path: "shippingLineCredit.invoiceCancelled.released" },
);
}
// ── Cancellation ───────────────────────────────────────────────────────────
/**

View File

@@ -0,0 +1,51 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { EntityManager, In, Repository } from "typeorm";
import {
ShippingLineInvoiceApproval,
ShippingLineInvoiceActionStatus,
} from "./entities/shipping-line-invoice-approval.entity";
@Injectable()
export class ShippingLineInvoiceApprovalsRepository extends BaseRepository<ShippingLineInvoiceApproval> {
constructor(
@InjectRepository(ShippingLineInvoiceApproval)
private readonly approvals: Repository<ShippingLineInvoiceApproval>,
) {
super(approvals);
}
findPendingByInvoice(
invoiceId: string,
): Promise<ShippingLineInvoiceApproval | null> {
return this.approvals.findOne({
where: { invoiceId, status: ShippingLineInvoiceActionStatus.Pending },
});
}
/** Pending requests for a page of invoices — one query, no N+1. */
findPendingByInvoiceIds(
invoiceIds: string[],
): Promise<ShippingLineInvoiceApproval[]> {
if (!invoiceIds.length) return Promise.resolve([]);
return this.approvals.find({
where: {
invoiceId: In(invoiceIds),
status: ShippingLineInvoiceActionStatus.Pending,
},
});
}
/** Load one request inside the caller's transaction, locked for decision. */
findByIdForUpdate(
manager: EntityManager,
id: string,
): Promise<ShippingLineInvoiceApproval | null> {
return manager.getRepository(ShippingLineInvoiceApproval).findOne({
where: { id },
lock: { mode: "pessimistic_write" },
});
}
}

View File

@@ -26,6 +26,7 @@ import { Booking } from '../bookings/entities/booking.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingPricingService } from '../bookings/booking-pricing.service';
import { formatRouteLabel } from '../routes/entities/route.entity';
import { isRoadService } from '../bookings/road.util';
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
@@ -154,6 +155,19 @@ export interface ExportTrainOption {
}>;
}
/** Form-entered cargo for a train-options probe (nothing persisted yet). */
export interface TrainOptionCargoOverrides {
/** Container types drive the per-type space. */
containerTypeIds?: string[];
/** Size labels ("20ft"/"40ft") when the form has no type ids. */
containerSizes?: string[];
/** Bulk counterparts of the container inputs. */
cargoTypeId?: string;
cargoTypeCode?: string;
/** Needed wagons estimate from the form (drives the `fits` flag). */
wagons?: number;
}
/** A train a paid-unallocated booking can board (route + capacity verified). */
export interface AllocationCandidate {
id: string;
@@ -1051,19 +1065,84 @@ export class BookingBatchService implements OnModuleInit {
async exportTrainOptionsForDay(
booking: Booking,
day: string,
overrides?: {
/** Cargo the customer is entering on a form (bare contract instance —
* nothing persisted yet): container types drive the per-type space. */
containerTypeIds?: string[];
/** Size labels ("20ft"/"40ft") when the form has no type ids. */
containerSizes?: string[];
/** Bulk counterparts of the container inputs. */
cargoTypeId?: string;
cargoTypeCode?: string;
/** Needed wagons estimate from the form (drives the `fits` flag). */
wagons?: number;
},
overrides?: TrainOptionCargoOverrides,
): Promise<ExportTrainOption[]> {
booking = await this.withCargoOverrides(booking, overrides);
const corridor = await this.trainSchedulesRepository.findAll({
where: [
// Dedicated shipping-line trains are never customer-booking targets.
{ status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() },
{ status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() },
],
});
const candidates = corridor
.filter(
(s) =>
s.scheduledDepartureDate != null &&
eatDay(s.scheduledDepartureDate) === day &&
s.direction === 'EXPORT',
)
.sort(
(a, b) =>
a.scheduledDepartureDate!.getTime() -
b.scheduledDepartureDate!.getTime(),
);
return this.buildTrainOptions(booking, candidates);
}
/**
* The same per-train wagon-availability cards, but for the trains DEDICATED
* to a shipping line on the booking's lane + day. Same option shape as the
* export picker so the portal reuses the same component; `isOpen`
* additionally respects the dedicated close offset (windowClosesAt), since
* these trains run no window cycle.
*/
async dedicatedTrainOptionsForDay(
booking: Booking,
day: string | null,
shippingLineCompanyId: string,
overrides?: TrainOptionCargoOverrides,
): Promise<ExportTrainOption[]> {
booking = await this.withCargoOverrides(booking, overrides);
const dedicated = await this.trainSchedulesRepository.findAll({
where: [
{ status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId },
{ status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId },
],
});
const candidates = dedicated
.filter(
(s) =>
s.scheduledDepartureDate != null &&
// A day narrows to that departure day; without one, every upcoming
// departure on the lane is listed (the picker's full card list).
(day
? eatDay(s.scheduledDepartureDate) === day
: s.scheduledDepartureDate.getTime() > Date.now() - 3_600_000) &&
(!booking.originYardId || s.originStationId === booking.originYardId) &&
(!booking.destinationYardId ||
s.destinationStationId === booking.destinationYardId),
)
.sort(
(a, b) =>
a.scheduledDepartureDate!.getTime() -
b.scheduledDepartureDate!.getTime(),
);
const options = await this.buildTrainOptions(booking, candidates);
const now = Date.now();
return options.map((o) => ({
...o,
isOpen:
o.isOpen &&
(o.bookingClosesAt == null || o.bookingClosesAt.getTime() > now),
}));
}
/** Resolve form-entered cargo onto an (unpersisted) booking probe. */
private async withCargoOverrides(
booking: Booking,
overrides?: TrainOptionCargoOverrides,
): Promise<Booking> {
const sizeFts = (overrides?.containerSizes ?? [])
.map((s) => parseInt(s, 10))
.filter((n) => Number.isFinite(n) && n > 0);
@@ -1095,26 +1174,14 @@ export class BookingBatchService implements OnModuleInit {
if (overrides?.wagons && overrides.wagons > 0) {
booking = { ...booking, wagonsRequired: overrides.wagons } as Booking;
}
const corridor = await this.trainSchedulesRepository.findAll({
where: [
// Dedicated shipping-line trains are never customer-booking targets.
{ status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() },
{ status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() },
],
});
const candidates = corridor
.filter(
(s) =>
s.scheduledDepartureDate != null &&
eatDay(s.scheduledDepartureDate) === day &&
s.direction === 'EXPORT',
)
.sort(
(a, b) =>
a.scheduledDepartureDate!.getTime() -
b.scheduledDepartureDate!.getTime(),
);
return booking;
}
/** One availability card per candidate schedule — the export picker's math. */
private async buildTrainOptions(
booking: Booking,
candidates: TrainSchedule[],
): Promise<ExportTrainOption[]> {
const wagonDims = await this.loadWagonDims();
const allowed = this.allowedDimsWithTypes(booking, wagonDims);
const neededWagons = this.wagonsFor(booking, wagonDims);
@@ -3185,6 +3252,99 @@ export class BookingBatchService implements OnModuleInit {
}
}
/**
* Auto-allocate an accepted SHIPPING-LINE booking onto its company's
* dedicated train for the booking's lane and shipment day.
*
* Runs at operation-accept: shipping lines pay later on the credit ledger,
* so there is no pay window between accept and wagon placement — the
* booking boards its train immediately. Customer bookings never come here;
* they keep the batch pool → reserve → pay → allocate pipeline.
*
* Wagon shortage parks the booking WAITING_FOR_WAGON on the schedule
* (without the PAID stamps the customer hold writes — nothing was paid).
* No dedicated train on the day is not an error: the booking simply stays
* in the ordinary day pool for the batch engine.
*/
async allocateShippingLineAccepted(bookingId: string): Promise<void> {
const booking = await this.dataSource.getRepository(Booking).findOne({
where: { id: bookingId },
relations: { bookingContainers: { containerType: true }, cargoType: true },
});
if (!booking?.shippingLineCompanyId || !booking.scheduledDate) return;
if (isRoadService(booking.serviceType)) return;
const day = eatDay(booking.scheduledDate);
const dedicated = await this.dataSource.getRepository(TrainSchedule).find({
where: [
{
shippingLineCompanyId: booking.shippingLineCompanyId,
originStationId: booking.originYardId,
destinationStationId: booking.destinationYardId,
status: TrainScheduleStatusEnum.Draft,
},
{
shippingLineCompanyId: booking.shippingLineCompanyId,
originStationId: booking.originYardId,
destinationStationId: booking.destinationYardId,
status: TrainScheduleStatusEnum.Scheduled,
},
],
});
const target = dedicated.find(
(s) =>
s.scheduledDepartureDate && eatDay(s.scheduledDepartureDate) === day,
);
if (!target) {
this.logger.log(
`[BATCH] shipping-line booking ${booking.reference} has no dedicated ` +
`train on ${day} — left in the day pool for the batch engine`,
);
return;
}
// Point the booking at its train BEFORE the shortage probe — the probe
// reads the link to size the need against that schedule's wagons.
await this.dataSource.getRepository(Booking).update(booking.id, {
trainScheduleId: target.id,
} as never);
booking.trainScheduleId = target.id;
// One dedicated train carries ONE booking: the accept claims the train by
// closing its booking window on the spot. Both gates a later booking
// passes — the day picker (isStillOpen on windowClosesAt) and the
// completion's dedicated-day check — read these fields, so a second
// booking can never pick this train.
await this.dataSource.getRepository(TrainSchedule).update(target.id, {
bookingWindowStatus: "CLOSED",
windowClosesAt: new Date(),
} as never);
this.notifyBoardChanged(target.id, "shipping_line_train_claimed");
const shortage =
await this.trainSchedulingService.previewPaidBookingWagonShortage(
target.id,
booking.id,
);
if (shortage) {
// Parked for staff to attach wagons — WITHOUT the customer hold's PAID
// stamps: a shipping line has paid nothing, its debt sits on the ledger.
await this.dataSource.getRepository(Booking).update(booking.id, {
schedulingStatus: "WAITING_FOR_WAGON",
} as never);
this.logger.warn(
`Shipping-line booking ${booking.reference} WAITING FOR WAGON on its ` +
`dedicated train ${target.reference ?? target.id}: needs ` +
`${shortage.wagonsNeeded} × ${shortage.wagonTypeCodes}, ` +
`${shortage.wagonsAvailable} available (short ${shortage.wagonsShort}).`,
);
this.notifyBoardChanged(target.id, "booking_waiting_wagon");
return;
}
await this.allocate(target.id, booking, "shipping_line");
}
/**
* One reminder per hold, shortly before its pay deadline (the window tick
* calls this every pass; `payment_reminder_sent_at` dedups). Skips paid
@@ -3544,7 +3704,7 @@ export class BookingBatchService implements OnModuleInit {
private async allocate(
scheduleId: string,
booking: Booking,
reason: "paid" | "gov",
reason: "paid" | "gov" | "shipping_line",
): Promise<void> {
// Stamp the computed wagon need on the link. Several callers pass a booking
// loaded without cargo relations (ensurePaidBookingAllocated), and a NULL

View File

@@ -12,6 +12,7 @@ import { Booking } from '../bookings/entities/booking.entity';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { resolveCompanyNotifyContact } from '../notifications/resolve-company-phone.util';
import { resolveShippingLineNotifyTarget } from '../notifications/resolve-shipping-line-contact.util';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { BATCH_TIMEZONE } from './booking-batch.constants';
@@ -66,10 +67,16 @@ export class BookingNotifierService {
): Promise<void> {
this.logger.log(`${logLabel}${this.ref(b)}`);
// One resolver for both channels — the company row's own email column is
// only set for a Fayda-verified owner (see companyNotifyEmailExpr).
const { phone, email } = b.companyId
? await resolveCompanyNotifyContact(this.dataSource, b.companyId)
: { phone: null, email: null };
// only set for a Fayda-verified owner (see companyNotifyEmailExpr). A
// shipping-line booking has no company; its contact is the line's row.
const { phone, email } = b.shippingLineCompanyId
? await resolveShippingLineNotifyTarget(
this.dataSource,
b.shippingLineCompanyId,
)
: b.companyId
? await resolveCompanyNotifyContact(this.dataSource, b.companyId)
: { phone: null, email: null };
if (phone) {
try {
@@ -90,13 +97,42 @@ export class BookingNotifierService {
}
}
/** Persist + push an in-app item to all portal users of the booking's company. */
/**
* Persist + push an in-app item to the booking's portal owner: every portal
* user of the company, or — for a shipping-line booking — the line's own
* account, deep-linked into the shipping-line app (/shipping-line/*).
*/
private inApp(
b: Booking,
title: string,
body: string,
overrides: Partial<NotifyInput> = {},
): void {
if (b.shippingLineCompanyId) {
void (async () => {
const { userId } = await resolveShippingLineNotifyTarget(
this.dataSource,
b.shippingLineCompanyId!,
);
if (!userId) return;
void this.inbox.notify({
recipients: { userIds: [userId] },
audience: NotificationAudience.PORTAL,
type: NotificationType.SCHEDULE_UPDATE,
title,
body,
data: { bookingId: b.id, reference: b.reference },
...overrides,
// After the spread: the bell must land the line on ITS booking page.
link: `/shipping-line/bookings/${b.id}`,
});
})().catch((err) =>
this.logger.warn(
`shipping-line inApp failed for ${this.ref(b)}: ${(err as Error).message}`,
),
);
return;
}
if (!b.companyId) return; // government/unlinked bookings have no portal users
void this.inbox.notify({
recipients: { companyId: b.companyId },
@@ -209,11 +245,19 @@ export class BookingNotifierService {
});
}
secured(b: Booking, reason: 'paid' | 'gov', scheduleId?: string | null): void {
secured(
b: Booking,
reason: 'paid' | 'gov' | 'shipping_line',
scheduleId?: string | null,
): void {
void (async () => {
const label = await this.scheduleLabel(scheduleId ?? b.trainScheduleId);
const msg = `Booking ${b.reference ?? b.id} allocated on ${label}${
reason === 'gov' ? ' (government)' : ''
reason === 'gov'
? ' (government)'
: reason === 'shipping_line'
? ' (shipping line)'
: ''
}.`;
void this.notifyContact(b, msg, 'ALLOCATED');
this.inApp(b, 'Wagon allocated', msg);

View File

@@ -4531,7 +4531,10 @@ export class TrainSchedulingService {
(b) =>
!(targetScheduleId && b.trainScheduleId === targetScheduleId) &&
!SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') &&
!b.isGovernment,
!b.isGovernment &&
// Shipping-line bookings pay later on the credit ledger — never PAID
// up front, schedulable from accept (FULLY_EXECUTED) like government.
!b.shippingLineCompanyId,
);
if (invalidStatus.length) {
const statuses = [...new Set(invalidStatus.map((b) => b.status))];
@@ -8647,7 +8650,12 @@ export class TrainSchedulingService {
.map((sb) => sb.booking)
.filter((b): b is Booking => Boolean(b));
const eligible = linkedBookings.filter(
(b) => SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') || b.isGovernment,
(b) =>
SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') ||
b.isGovernment ||
// Shipping-line bookings board without paying up front — their charge
// sits on the credit ledger, so accept (FULLY_EXECUTED) is boardable.
Boolean(b.shippingLineCompanyId),
);
if (!eligible.length) return empty;

View File

@@ -595,6 +595,28 @@ export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [
"edr_freight_app:shipping_line_credits:cancel",
"Cancel (write off) an unbilled shipping-line credit",
),
// Two-step manual actions on credit invoices: request grants per action,
// decision grants that apply to any pending request.
perm(
"d2c00001-0001-4000-8000-000000000004",
"edr_freight_app:shipping_line_credits:invoice_mark_paid",
"Request marking a shipping-line credit invoice paid (offline payment)",
),
perm(
"d2c00001-0001-4000-8000-000000000005",
"edr_freight_app:shipping_line_credits:invoice_approve",
"Approve any pending shipping-line credit invoice request",
),
perm(
"d2c00001-0001-4000-8000-000000000006",
"edr_freight_app:shipping_line_credits:invoice_cancel",
"Request cancelling a shipping-line credit invoice",
),
perm(
"d2c00001-0001-4000-8000-000000000007",
"edr_freight_app:shipping_line_credits:invoice_reject",
"Reject any pending shipping-line credit invoice request",
),
];
// E. First / last mile operations
@@ -1829,6 +1851,18 @@ export const FREIGHT_PERMS = {
invoice: "edr_freight_app:shipping_line_credits:invoice",
/** Write off an unbilled credit — separate grant: it erases a debt. */
cancel: "edr_freight_app:shipping_line_credits:cancel",
// Two-step manual actions on credit invoices, gated purely by permission:
// finance-level REQUEST grants (per action) and decision grants that apply
// to ANY pending request — including the holder's own.
/** Request recording an offline payment against a credit invoice. */
invoiceMarkPaid:
"edr_freight_app:shipping_line_credits:invoice_mark_paid",
/** Request voiding a credit invoice (credits return to unbilled). */
invoiceCancel: "edr_freight_app:shipping_line_credits:invoice_cancel",
/** Approve any pending invoice request (mark-paid or cancel). */
invoiceApprove: "edr_freight_app:shipping_line_credits:invoice_approve",
/** Reject any pending invoice request. */
invoiceReject: "edr_freight_app:shipping_line_credits:invoice_reject",
},
payments: {
view: "edr_freight_app:payments:view",
@@ -2377,6 +2411,13 @@ export const ROLE_PERMISSION_PRESETS = {
// exceptional operations, and are assigned to named admins rather than a role preset.
FREIGHT_PERMS.payments.view,
FREIGHT_PERMS.bookings.wagonCancellationView,
// Shipping-line credit ledger is a Finance surface: bill batches into
// invoices and RAISE manual invoice actions. Approval of those actions is
// deliberately absent — it sits with the chief (makerchecker).
FREIGHT_PERMS.shippingLineCredits.view,
FREIGHT_PERMS.shippingLineCredits.invoice,
FREIGHT_PERMS.shippingLineCredits.invoiceMarkPaid,
FREIGHT_PERMS.shippingLineCredits.invoiceCancel,
],
// Global Logistics: manages ONLY the customs-clearance queue. Scoped out of
// the general booking-request list (no bookings:view) — instead a dedicated
@@ -2479,6 +2520,11 @@ export const POSITION_PERMISSION_PRESETS = {
FREIGHT_PERMS.invoices.view,
FREIGHT_PERMS.invoices.export,
FREIGHT_PERMS.payments.view,
// Decision side of the credit-invoice two-step: finance raises
// mark-paid/cancel requests, the chief approves or rejects them.
FREIGHT_PERMS.shippingLineCredits.view,
FREIGHT_PERMS.shippingLineCredits.invoiceApprove,
FREIGHT_PERMS.shippingLineCredits.invoiceReject,
]),
// Director additionally manages train scheduling + rail fleet (same block the
// operation officer/chief hold), on top of the approval-chain role preset,

View File

@@ -37,6 +37,7 @@ import DocumentClearanceListPage from "./pages/bookings/DocumentClearanceListPag
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
import CustomersPage from "./pages/customers/CustomersPage";
import ShippingLineCompaniesPage from "./pages/shipping-lines/ShippingLineCompaniesPage";
import ShippingLineCreditsPage from "./pages/shipping-lines/ShippingLineCreditsPage";
import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
import FinanceHubPage from "./pages/invoices/FinanceHubPage";
import MyProfilePage from "./pages/dashboard/MyProfilePage";
@@ -309,6 +310,16 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="shipping-line-credits"
element={
<RequirePermission
permission={FREIGHT_PERMS.shippingLineCredits.view}
>
<ShippingLineCreditsPage />
</RequirePermission>
}
/>
<Route
path="invoices"
element={

View File

@@ -1,5 +1,5 @@
import { Package } from "lucide-react";
import { SimpleGrid, Divider, Box, Table, Text } from "@mantine/core";
import { SimpleGrid, Divider, Box, Table, Text, Badge } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { cargoTonsAndItems } from "@/utils/cargoWeight";
@@ -16,6 +16,19 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
const containers = booking.bookingContainers ?? [];
const { tons, items } = cargoTonsAndItems(booking);
// Booking-level flags OR any container line carrying a count — the flag can
// lag the lines (per-line opt-ins), so either alone must light the tile.
const isHazardous =
booking.isHazardous ||
containers.some((c) => Number(c.hazardousQuantity ?? 0) > 0);
const isReefer =
booking.isReefer ||
containers.some((c) => Number(c.reeferQuantity ?? 0) > 0);
const showHandlingColumns = containers.some(
(c) =>
Number(c.hazardousQuantity ?? 0) > 0 || Number(c.reeferQuantity ?? 0) > 0,
);
return (
<SectionCard icon={Package} title="Cargo specifications" accent="orange">
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
@@ -27,11 +40,33 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
{items != null && <MetricTile label="Items" value={`${items}`} />}
<MetricTile
label="Hazardous"
value={booking.isHazardous ? "Yes" : "No"}
highlight={booking.isHazardous}
value={isHazardous ? "Yes" : "No"}
highlight={isHazardous}
/>
<MetricTile
label="Refrigerated"
value={isReefer ? "Yes" : "No"}
highlight={isReefer}
/>
</SimpleGrid>
{/* Handling that changes how the yard treats the shipment is flagged
loudly, not buried in the grid. */}
{(isHazardous || isReefer) && (
<Box mt="sm">
{isHazardous && (
<Badge color="red" variant="filled" radius="sm" mr={8}>
Hazardous cargo
</Badge>
)}
{isReefer && (
<Badge color="blue" variant="filled" radius="sm">
Refrigerated cargo
</Badge>
)}
</Box>
)}
{containers.length > 0 && (
<>
<Divider my="lg" color="var(--mantine-color-gray-2)" />
@@ -42,6 +77,8 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
<Table.Th>Container type</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>VGM / unit</Table.Th>
{showHandlingColumns && <Table.Th>Hazardous</Table.Th>}
{showHandlingColumns && <Table.Th>Reefer</Table.Th>}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
@@ -54,6 +91,28 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
</Table.Td>
<Table.Td>{c.quantity}</Table.Td>
<Table.Td>{c.vgmPerUnitTons} t</Table.Td>
{showHandlingColumns && (
<Table.Td>
{Number(c.hazardousQuantity ?? 0) > 0 ? (
<Text fw={700} c="red" size="sm">
{c.hazardousQuantity}
</Text>
) : (
"—"
)}
</Table.Td>
)}
{showHandlingColumns && (
<Table.Td>
{Number(c.reeferQuantity ?? 0) > 0 ? (
<Text fw={700} c="blue" size="sm">
{c.reeferQuantity}
</Text>
) : (
"—"
)}
</Table.Td>
)}
</Table.Tr>
))}
</Table.Tbody>

View File

@@ -24,6 +24,7 @@ import {
Send,
Settings,
ShieldCheck,
HandCoins,
Ship,
SlidersHorizontal,
Train,
@@ -76,6 +77,12 @@ export const buildSidebarSections = (
icon: <Ship />,
permission: FREIGHT_PERMS.shippingLines.view,
},
{
label: "Shipping Line Credits",
href: "/dashboard/shipping-line-credits",
icon: <HandCoins />,
permission: FREIGHT_PERMS.shippingLineCredits.view,
},
{
label: "Contracts",
href: "/dashboard/contract-requests",

View File

@@ -0,0 +1,363 @@
import {
Badge,
Button,
Group,
Modal,
Stack,
Text,
Textarea,
TextInput,
} from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import { Ban, Check, HandCoins, X } from "lucide-react";
import { useState } from "react";
import { useAuth } from "@/auth/useAuth";
import { formatMoney } from "@/components/customers";
import { useToast } from "@/hooks/use-toast";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api";
import type {
CreditInvoiceActionType,
CreditInvoicePendingAction,
} from "@/types/shippingLineCredit";
/** The slice of an invoice row the actions need — both list pages have it. */
export interface CreditInvoiceActionTarget {
id: string;
invoiceNumber: string;
status: string;
currency: string;
totalAmount: string | number;
paidAmount: string | number;
balanceAmount: string | number;
}
/** Statuses an offline payment can still be recorded against. */
const MARK_PAID_STATUSES = new Set([
"ISSUED",
"PENDING",
"PAYMENT_PROCESSING",
"PARTIALLY_PAID",
"OVERDUE",
]);
const ACTION_LABEL: Record<CreditInvoiceActionType, string> = {
MARK_PAID: "Mark paid",
CANCEL: "Cancel invoice",
};
export interface CreditInvoiceActionsProps {
invoice: CreditInvoiceActionTarget;
pendingAction: CreditInvoicePendingAction | null;
}
/**
* Two-step actions for ONE shipping-line credit invoice, embeddable in any
* invoice list. Gated purely by permission: the request grants raise
* mark-paid / cancel, the approve/reject grants decide ANY pending request —
* the holder's own included. Renders only the buttons the signed-in user's
* grants allow; the API enforces the same gates server-side.
*/
export default function CreditInvoiceActions({
invoice,
pendingAction,
}: CreditInvoiceActionsProps) {
const { user } = useAuth();
const { toast } = useToast();
const canRequestPaid = hasPermission(
user,
FREIGHT_PERMS.shippingLineCredits.invoiceMarkPaid,
);
const canRequestCancel = hasPermission(
user,
FREIGHT_PERMS.shippingLineCredits.invoiceCancel,
);
const canApprove = hasPermission(
user,
FREIGHT_PERMS.shippingLineCredits.invoiceApprove,
);
const canReject = hasPermission(
user,
FREIGHT_PERMS.shippingLineCredits.invoiceReject,
);
const [requestAction, setRequestActionModal] =
useState<CreditInvoiceActionType | null>(null);
const [reason, setReason] = useState("");
const [paymentReference, setPaymentReference] = useState("");
const [decideApprove, setDecideApprove] = useState<boolean | null>(null);
const [decisionNote, setDecisionNote] = useState("");
const closeRequest = () => {
setRequestActionModal(null);
setReason("");
setPaymentReference("");
};
const closeDecide = () => {
setDecideApprove(null);
setDecisionNote("");
};
const { mutate: submitRequest, isPending: isRequesting } = useMutation(
api.shippingLineCredits.requestInvoiceAction.mutationOptions({
onSuccess: (_, variables) => {
closeRequest();
toast({
title: "Request submitted",
description: `${ACTION_LABEL[variables.action]} on ${invoice.invoiceNumber} now awaits a chief's approval.`,
});
},
onError: (err) =>
toast({
title: "Could not submit request",
description: err.message,
variant: "destructive",
}),
}),
);
const { mutate: submitDecision, isPending: isDeciding } = useMutation(
api.shippingLineCredits.decideInvoiceAction.mutationOptions({
onSuccess: (_, variables) => {
closeDecide();
toast({
title: variables.approve ? "Request approved" : "Request rejected",
description: variables.approve
? pendingAction?.action === "MARK_PAID"
? "The offline payment was recorded; the invoice and its credits are now paid."
: "The invoice was cancelled; its credits returned to the unbilled pool."
: "The request was rejected and nothing was changed.",
});
},
onError: (err) =>
toast({
title: "Could not decide request",
description: err.message,
variant: "destructive",
}),
}),
);
let body = null;
if (pendingAction) {
body = (
<Stack gap={6} py={4}>
<Badge variant="light" color="orange" title={pendingAction.reason}>
{ACTION_LABEL[pendingAction.action]} awaiting approval
</Badge>
{canApprove || canReject ? (
<Group gap={6} wrap="nowrap">
{canApprove ? (
<Button
size="compact-xs"
color="edr-green"
leftSection={<Check size={12} />}
onClick={() => setDecideApprove(true)}
>
Approve
</Button>
) : null}
{canReject ? (
<Button
size="compact-xs"
variant="outline"
color="red"
leftSection={<X size={12} />}
onClick={() => setDecideApprove(false)}
>
Reject
</Button>
) : null}
</Group>
) : null}
</Stack>
);
} else {
const showMarkPaid =
canRequestPaid && MARK_PAID_STATUSES.has(invoice.status);
const showCancel =
canRequestCancel &&
invoice.status !== "CANCELLED" &&
invoice.status !== "PAID" &&
invoice.status !== "REFUNDED" &&
Number(invoice.paidAmount) === 0;
body =
!showMarkPaid && !showCancel ? (
<Text size="sm" c="dimmed">
</Text>
) : (
<Group gap={6} wrap="nowrap">
{showMarkPaid ? (
<Button
size="compact-xs"
variant="light"
color="edr-green"
leftSection={<HandCoins size={12} />}
onClick={() => setRequestActionModal("MARK_PAID")}
>
Mark paid
</Button>
) : null}
{showCancel ? (
<Button
size="compact-xs"
variant="light"
color="red"
leftSection={<Ban size={12} />}
onClick={() => setRequestActionModal("CANCEL")}
>
Cancel
</Button>
) : null}
</Group>
);
}
return (
<>
{body}
{/* Maker: raise the request. */}
<Modal
opened={requestAction !== null}
onClose={closeRequest}
title={
requestAction
? `${ACTION_LABEL[requestAction]}${invoice.invoiceNumber}`
: ""
}
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
{requestAction === "MARK_PAID"
? "Records a full offline settlement of the outstanding balance. Takes effect only after a chief approves."
: "Voids the invoice and returns its credits to the unbilled pool. Takes effect only after a chief approves."}
</Text>
{requestAction === "MARK_PAID" ? (
<TextInput
label="Payment reference"
description="Bank slip / transfer number, if any."
value={paymentReference}
onChange={(e) => setPaymentReference(e.currentTarget.value)}
/>
) : null}
<Textarea
label="Reason"
withAsterisk
minRows={2}
placeholder={
requestAction === "MARK_PAID"
? "Paid by bank transfer, slip #…"
: "Raised in error / rebilling with corrections…"
}
value={reason}
onChange={(e) => setReason(e.currentTarget.value)}
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={closeRequest}
disabled={isRequesting}
>
Cancel
</Button>
<Button
loading={isRequesting}
disabled={reason.trim().length < 3}
onClick={() =>
requestAction &&
submitRequest({
invoiceId: invoice.id,
action: requestAction,
reason: reason.trim(),
paymentReference: paymentReference.trim() || undefined,
})
}
>
Submit for approval
</Button>
</Group>
</Stack>
</Modal>
{/* Checker: decide the request. */}
<Modal
opened={decideApprove !== null}
onClose={closeDecide}
title={
pendingAction
? `${decideApprove ? "Approve" : "Reject"}: ${ACTION_LABEL[pendingAction.action]}${invoice.invoiceNumber}`
: ""
}
centered
>
<Stack gap="md">
{pendingAction ? (
<Stack gap={4}>
<Text size="sm">
<Text component="span" c="dimmed">
Requested reason:{" "}
</Text>
{pendingAction.reason}
</Text>
{pendingAction.paymentReference ? (
<Text size="sm">
<Text component="span" c="dimmed">
Payment reference:{" "}
</Text>
{pendingAction.paymentReference}
</Text>
) : null}
</Stack>
) : null}
{decideApprove && pendingAction ? (
<Text size="sm" c="dimmed">
{pendingAction.action === "MARK_PAID"
? `Approving records ${formatMoney(
Number(invoice.balanceAmount ?? invoice.totalAmount),
invoice.currency,
)} as paid offline and settles the invoice's credits.`
: "Approving cancels the invoice and returns its credits to the unbilled pool."}
</Text>
) : null}
<Textarea
label={decideApprove ? "Note (optional)" : "Rejection note"}
withAsterisk={!decideApprove}
minRows={2}
value={decisionNote}
onChange={(e) => setDecisionNote(e.currentTarget.value)}
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={closeDecide}
disabled={isDeciding}
>
Back
</Button>
<Button
color={decideApprove ? "edr-green" : "red"}
loading={isDeciding}
disabled={!decideApprove && !decisionNote.trim()}
onClick={() =>
pendingAction &&
decideApprove !== null &&
submitDecision({
approvalId: pendingAction.id,
approve: decideApprove,
note: decisionNote.trim() || undefined,
})
}
>
{decideApprove ? "Approve & execute" : "Reject request"}
</Button>
</Group>
</Stack>
</Modal>
</>
);
}

View File

@@ -36,6 +36,40 @@ export const QUERY_KEYS = {
["shipping-line-companies", "detail", id] as const,
},
SHIPPING_LINE_CREDITS: {
ROOT: ["shipping-line-credits"] as const,
invoices: (
page: number,
pageSize: number,
status?: string,
shippingLineId?: string,
) =>
[
"shipping-line-credits",
"invoices",
shippingLineId ?? "all",
page,
pageSize,
status ?? "all",
] as const,
summary: (shippingLineId?: string) =>
["shipping-line-credits", "summary", shippingLineId ?? "all"] as const,
list: (
page: number,
pageSize: number,
status?: string,
shippingLineId?: string,
) =>
[
"shipping-line-credits",
"list",
shippingLineId ?? "all",
page,
pageSize,
status ?? "all",
] as const,
},
CUSTOMERS: {
ROOT: ["customers"] as const,
stats: ["customers", "stats"] as const,

View File

@@ -88,6 +88,22 @@ export const URL_CONSTANTS = {
`/shipping-line-companies/${id}/resend-activation`,
},
/** Finance's view of what shipping lines owe (use now, pay later). */
SHIPPING_LINE_CREDITS: {
BASE: "/shipping-line-credits",
SUMMARY: "/shipping-line-credits/summary",
INVOICE: "/shipping-line-credits/invoice",
INVOICES: "/shipping-line-credits/invoices",
MARK_PAID_REQUEST: (invoiceId: string) =>
`/shipping-line-credits/invoices/${invoiceId}/mark-paid-request`,
CANCEL_REQUEST: (invoiceId: string) =>
`/shipping-line-credits/invoices/${invoiceId}/cancel-request`,
APPROVE_ACTION: (approvalId: string) =>
`/shipping-line-credits/invoice-actions/${approvalId}/approve`,
REJECT_ACTION: (approvalId: string) =>
`/shipping-line-credits/invoice-actions/${approvalId}/reject`,
},
COMPANIES: {
BASE: "/companies",
STATS: "/companies/stats",

View File

@@ -129,6 +129,15 @@ export const FREIGHT_PERMS = {
update: "edr_freight_app:shipping_lines:update",
resetPassword: "edr_freight_app:shipping_lines:reset-password",
},
shippingLineCredits: {
view: "edr_freight_app:shipping_line_credits:view",
invoice: "edr_freight_app:shipping_line_credits:invoice",
cancel: "edr_freight_app:shipping_line_credits:cancel",
invoiceMarkPaid: "edr_freight_app:shipping_line_credits:invoice_mark_paid",
invoiceCancel: "edr_freight_app:shipping_line_credits:invoice_cancel",
invoiceApprove: "edr_freight_app:shipping_line_credits:invoice_approve",
invoiceReject: "edr_freight_app:shipping_line_credits:invoice_reject",
},
payments: {
view: "edr_freight_app:payments:view",
},

View File

@@ -21,6 +21,7 @@ import {
formatMoney,
humanize,
} from "@/components/customers";
import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActions";
import { api } from "@/services/api";
import type { Invoice } from "@/types/invoice";
import {
@@ -58,6 +59,26 @@ export default function InvoicesPanel() {
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
// Shipping-line credit invoices carry makerchecker actions (mark paid /
// cancel). One batched lookup fetches the visible rows' pending requests.
const creditInvoiceIds = useMemo(
() =>
rows
.filter((inv) => inv.source === "shipping_line_credit")
.map((inv) => inv.id),
[rows],
);
const { data: pendingActions } = useQuery(
api.shippingLineCredits.pendingInvoiceActions.queryOptions({
input: { invoiceIds: creditInvoiceIds },
enabled: creditInvoiceIds.length > 0,
}),
);
const pendingByInvoice = useMemo(
() => new Map((pendingActions ?? []).map((p) => [p.invoiceId, p])),
[pendingActions],
);
const columns: ColumnDef<Invoice>[] = useMemo(
() => [
{
@@ -122,8 +143,30 @@ export default function InvoicesPanel() {
</Text>
),
},
{
id: "actions",
header: "Actions",
cell: ({ row }) => {
const inv = row.original;
// Only shipping-line credit invoices have manual makerchecker
// actions; every other source settles through its own flow.
if (inv.source !== "shipping_line_credit") {
return (
<Text size="sm" c="dimmed">
</Text>
);
}
return (
<CreditInvoiceActions
invoice={inv}
pendingAction={pendingByInvoice.get(inv.id) ?? null}
/>
);
},
},
],
[],
[pendingByInvoice],
);
return (

View File

@@ -0,0 +1,279 @@
import {
Badge,
Box,
Button,
Card,
Group,
Select,
Stack,
Text,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { Calendar, FilterX, RefreshCw, Ship } from "lucide-react";
import { useMemo, useState } from "react";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { formatDate, formatMoney } from "@/components/customers";
import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActions";
import { api } from "@/services/api";
import type { CreditInvoice } from "@/types/shippingLineCredit";
import {
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
const INVOICE_STATUS_META: Record<string, { label: string; color: string }> = {
DRAFT: { label: "Draft", color: "gray" },
ISSUED: { label: "Issued", color: "orange" },
PENDING: { label: "Pending", color: "orange" },
PAYMENT_PROCESSING: { label: "Processing", color: "blue" },
PARTIALLY_PAID: { label: "Partially paid", color: "yellow" },
PAID: { label: "Paid", color: "green" },
OVERDUE: { label: "Overdue", color: "red" },
CANCELLED: { label: "Cancelled", color: "gray" },
REFUNDED: { label: "Refunded", color: "blue" },
EXPIRED: { label: "Expired", color: "red" },
};
const STATUS_OPTIONS = Object.entries(INVOICE_STATUS_META).map(
([value, meta]) => ({ value, label: meta.label }),
);
/**
* Invoices minted from credit batches. The actions column is the shared
* makerchecker component (also embedded on the Finance hub's invoice list):
* finance requests mark-paid / cancel, a chief approves or rejects.
*/
export default function ShippingLineCreditInvoicesPanel() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [status, setStatus] = useState<string | null>(null);
const [shippingLineId, setShippingLineId] = useState<string | null>(null);
const { data: companies } = useQuery(
api.shippingLineCompanies.list.queryOptions({
input: { page: 1, limit: 100 },
}),
);
const lineOptions = useMemo(
() =>
(companies?.items ?? []).map((sl) => ({ value: sl.id, label: sl.name })),
[companies],
);
const { data, isLoading, isError, error, refetch, isFetching } = useQuery(
api.shippingLineCredits.listInvoices.queryOptions({
input: {
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
status: status ?? undefined,
shippingLineId: shippingLineId ?? undefined,
},
}),
);
const rows = data?.items ?? [];
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const activeFilterCount = (shippingLineId ? 1 : 0) + (status ? 1 : 0);
const resetPage = () =>
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
const columns: ColumnDef<CreditInvoice>[] = useMemo(
() => [
{
id: "invoice",
header: () => <span className={bookingTable.headerCell}>Invoice</span>,
cell: ({ row }) => {
const inv = row.original;
return (
<div className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}>
<Ship className="size-4" strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="truncate font-mono text-sm font-semibold text-foreground">
{inv.invoiceNumber}
</p>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{inv.shippingLineName ?? "—"}
</p>
</div>
</div>
);
},
},
{
id: "issued",
header: () => <span className={bookingTable.headerCell}>Issued</span>,
cell: ({ row }) => (
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Calendar className="size-3.5" />
{row.original.issuedAt ? formatDate(row.original.issuedAt) : "—"}
</span>
),
},
{
id: "due",
header: () => <span className={bookingTable.headerCell}>Due</span>,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{row.original.dueAt ? formatDate(row.original.dueAt) : "—"}
</span>
),
},
{
id: "amount",
header: () => <span className={bookingTable.headerCell}>Amount</span>,
cell: ({ row }) => (
<span className="text-sm font-semibold text-foreground">
{formatMoney(
Number(row.original.totalAmount),
row.original.currency,
)}
</span>
),
},
{
id: "balance",
header: () => <span className={bookingTable.headerCell}>Balance</span>,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{formatMoney(
Number(row.original.balanceAmount ?? row.original.totalAmount),
row.original.currency,
)}
</span>
),
},
{
id: "status",
header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: ({ row }) => {
const meta = INVOICE_STATUS_META[row.original.status] ?? {
label: row.original.status,
color: "gray",
};
return (
<Badge variant="light" color={meta.color}>
{meta.label}
</Badge>
);
},
},
{
id: "actions",
header: () => <span className={bookingTable.headerCell}>Actions</span>,
cell: ({ row }) => (
<CreditInvoiceActions
invoice={row.original}
pendingAction={row.original.pendingAction}
/>
),
},
],
[],
);
return (
<Stack gap="lg">
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap">
<Group gap="sm" wrap="wrap">
<Select
placeholder="All shipping lines"
data={lineOptions}
value={shippingLineId}
onChange={(v) => {
setShippingLineId(v);
resetPage();
}}
clearable
searchable
radius="lg"
style={{ minWidth: 220 }}
/>
<Select
placeholder="All statuses"
data={STATUS_OPTIONS}
value={status}
onChange={(v) => {
setStatus(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 160 }}
/>
{activeFilterCount > 0 ? (
<Button
variant="subtle"
color="gray"
radius="lg"
leftSection={<FilterX size={16} />}
onClick={() => {
setShippingLineId(null);
setStatus(null);
resetPage();
}}
>
Clear filters ({activeFilterCount})
</Button>
) : null}
</Group>
<Group gap="sm" wrap="nowrap">
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
<Button
variant="default"
size="compact-sm"
leftSection={<RefreshCw size={14} />}
loading={isFetching}
onClick={() => void refetch()}
>
Refresh
</Button>
</Group>
</Group>
</Box>
<Box style={{ overflowX: "auto" }} w="100%">
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
emptyMessage="No credit invoices yet — generate one from the Credits tab."
error={
isError
? {
message: error?.message ?? "Failed to load invoices.",
onRetry: () => void refetch(),
}
: undefined
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
</Stack>
</Card>
</Stack>
);
}

View File

@@ -0,0 +1,65 @@
import { Stack, Tabs } from "@mantine/core";
import { HandCoins, Receipt } from "lucide-react";
import { useSearchParams } from "react-router-dom";
import { PageContainer, PageHeader } from "@/components/page";
import ShippingLineCreditInvoicesPanel from "./ShippingLineCreditInvoicesPanel";
import ShippingLineCreditsPanel from "./ShippingLineCreditsPanel";
/**
* Finance's view of what shipping lines owe. Two URL-linkable tabs (?tab=,
* FinanceHubPage convention): the credit ledger (select unbilled credits →
* generate an invoice) and the invoices minted from it (makerchecker
* mark-paid / cancel actions).
*/
export default function ShippingLineCreditsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const activeTab =
searchParams.get("tab") === "invoices" ? "invoices" : "credits";
const handleTabChange = (value: string | null) => {
if (!value) return;
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.set("tab", value);
return next;
},
{ replace: true },
);
};
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title="Shipping Line Credits"
subtitle={
activeTab === "invoices"
? "Invoices billed from credit batches. Manual mark-paid / cancel actions need a second approver."
: "What each line owes — outstanding totals and the full credit ledger."
}
/>
<Tabs value={activeTab} onChange={handleTabChange} keepMounted={false}>
<Tabs.List>
<Tabs.Tab value="credits" leftSection={<HandCoins size={16} />}>
Credits
</Tabs.Tab>
<Tabs.Tab value="invoices" leftSection={<Receipt size={16} />}>
Invoices
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="credits" pt="lg">
<ShippingLineCreditsPanel />
</Tabs.Panel>
<Tabs.Panel value="invoices" pt="lg">
<ShippingLineCreditInvoicesPanel />
</Tabs.Panel>
</Tabs>
</Stack>
</PageContainer>
);
}

View File

@@ -0,0 +1,553 @@
import {
Badge,
Box,
Button,
Card,
Checkbox,
Divider,
Group,
Modal,
NumberInput,
Select,
Stack,
Text,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Calendar,
Clock,
FilterX,
HandCoins,
Receipt,
RefreshCw,
Ship,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useAuth } from "@/auth/useAuth";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { formatDate, formatMoney } from "@/components/customers";
import { KpiStrip } from "@/components/page";
import { useToast } from "@/hooks/use-toast";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api";
import type {
ShippingLineCredit,
ShippingLineCreditStatus,
} from "@/types/shippingLineCredit";
import {
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
const STATUS_META: Record<
ShippingLineCreditStatus,
{ label: string; color: string }
> = {
UNBILLED: { label: "Unbilled", color: "orange" },
BILLED: { label: "Billed", color: "blue" },
PAID: { label: "Paid", color: "green" },
CANCELLED: { label: "Cancelled", color: "gray" },
};
const STATUS_OPTIONS = Object.entries(STATUS_META).map(([value, meta]) => ({
value,
label: meta.label,
}));
/**
* Every shipping line's credits in one list — finance's landing view, styled
* to match the booking-requests page. Summary cells total the current filter
* scope (all lines by default); the selects narrow both cells and ledger.
*/
export default function ShippingLineCreditsPanel() {
const { user } = useAuth();
const { toast } = useToast();
const [shippingLineId, setShippingLineId] = useState<string | null>(null);
const [status, setStatus] = useState<ShippingLineCreditStatus | null>(null);
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const canInvoice = hasPermission(
user,
FREIGHT_PERMS.shippingLineCredits.invoice,
);
// Selection for batch invoicing, kept as id → credit so it survives page
// changes and can total itself. One invoice has one payer, so everything
// selected must belong to the same shipping line — enforced here so the
// API's rejection is never the first time staff hears about it.
const [selected, setSelected] = useState<Map<string, ShippingLineCredit>>(
new Map(),
);
const [invoiceOpen, setInvoiceOpen] = useState(false);
const [dueInDays, setDueInDays] = useState<number | "">("");
const selectedCredits = useMemo(() => [...selected.values()], [selected]);
const selectedLineId = selectedCredits[0]?.shippingLineCompanyId ?? null;
const selectedTotal = selectedCredits.reduce(
(sum, c) => sum + Number(c.amount),
0,
);
const toggleSelected = (credit: ShippingLineCredit) =>
setSelected((prev) => {
const next = new Map(prev);
if (next.has(credit.id)) next.delete(credit.id);
else next.set(credit.id, credit);
return next;
});
const clearSelection = () => setSelected(new Map());
// ponytail: first 100 lines in the picker; server-side search when a real
// deployment outgrows that.
const { data: companies } = useQuery(
api.shippingLineCompanies.list.queryOptions({
input: { page: 1, limit: 100 },
}),
);
const lineOptions = useMemo(
() =>
(companies?.items ?? []).map((sl) => ({
value: sl.id,
label: sl.scacCode ? `${sl.name} (${sl.scacCode})` : sl.name,
})),
[companies],
);
const {
data: summary,
isLoading: summaryLoading,
refetch: refetchSummary,
} = useQuery(
api.shippingLineCredits.summary.queryOptions({
input: { shippingLineId: shippingLineId ?? undefined },
}),
);
const {
data: ledger,
isLoading,
isError,
error,
refetch,
isFetching,
} = useQuery(
api.shippingLineCredits.list.queryOptions({
input: {
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
status: status ?? undefined,
shippingLineId: shippingLineId ?? undefined,
},
}),
);
const rows = ledger?.items ?? [];
const total = ledger?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const activeFilterCount = (shippingLineId ? 1 : 0) + (status ? 1 : 0);
const resetPage = () =>
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
const clearFilters = () => {
setShippingLineId(null);
setStatus(null);
resetPage();
};
const handleRefresh = () => {
void refetch();
void refetchSummary();
};
const { mutate: generateInvoice, isPending: isInvoicing } = useMutation(
api.shippingLineCredits.generateInvoice.mutationOptions({
onSuccess: (invoice) => {
setInvoiceOpen(false);
clearSelection();
setDueInDays("");
toast({
title: `Invoice ${invoice.invoiceNumber} generated`,
description: `${formatMoney(Number(invoice.totalAmount), invoice.currency)} billed across ${selectedCredits.length} credit${selectedCredits.length === 1 ? "" : "s"}.`,
});
},
onError: (err) => {
toast({
title: "Could not generate invoice",
description: err.message,
variant: "destructive",
});
// A concurrent edit (someone else billed a selected credit) is the
// usual cause — resync so stale rows drop out of the list.
handleRefresh();
},
}),
);
const columns: ColumnDef<ShippingLineCredit>[] = useMemo(
() => [
...(canInvoice
? [
{
id: "select",
size: 40,
header: () => null,
cell: ({ row }: { row: { original: ShippingLineCredit } }) => {
const credit = row.original;
const selectable =
credit.status === "UNBILLED" &&
(selectedLineId === null ||
credit.shippingLineCompanyId === selectedLineId);
return (
<Checkbox
size="sm"
checked={selected.has(credit.id)}
disabled={!selectable}
title={
credit.status !== "UNBILLED"
? "Only unbilled credits can be invoiced"
: !selectable
? "One invoice has one payer — selection already holds another line's credits"
: undefined
}
onChange={() => toggleSelected(credit)}
aria-label="Select credit for invoicing"
/>
);
},
},
]
: []),
{
id: "shippingLine",
header: () => (
<span className={bookingTable.headerCell}>Shipping line</span>
),
cell: ({ row }) => {
const credit = row.original;
return (
<div className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}>
<Ship className="size-4" strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="truncate font-medium text-foreground">
{credit.shippingLineCompany?.name ?? "—"}
</p>
<p className="mt-0.5 truncate font-mono text-xs text-muted-foreground">
{credit.booking?.reference ?? "—"}
</p>
</div>
</div>
);
},
},
{
id: "description",
header: () => (
<span className={bookingTable.headerCell}>Description</span>
),
cell: ({ row }) => (
<span className="block max-w-[16rem] truncate py-1 text-sm text-muted-foreground">
{row.original.description ?? "—"}
</span>
),
},
{
id: "amount",
header: () => <span className={bookingTable.headerCell}>Amount</span>,
cell: ({ row }) => (
<span className="text-sm font-semibold text-foreground">
{formatMoney(Number(row.original.amount), row.original.currency)}
</span>
),
},
{
id: "status",
header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: ({ row }) => {
const meta = STATUS_META[row.original.status];
return (
<Badge variant="light" color={meta.color}>
{meta.label}
</Badge>
);
},
},
{
id: "invoice",
header: () => <span className={bookingTable.headerCell}>Invoice</span>,
cell: ({ row }) => {
const inv = row.original.invoice;
return inv ? (
<span className="truncate font-mono text-xs text-foreground">
{inv.invoiceNumber}
</span>
) : (
<span className="text-xs text-muted-foreground"></span>
);
},
},
{
id: "createdAt",
header: () => <span className={bookingTable.headerCell}>Recorded</span>,
cell: ({ row }) => (
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Calendar className="size-3.5" />
{formatDate(row.original.createdAt)}
</span>
),
},
],
// Selection state drives the checkbox column's checked/disabled rendering.
// eslint-disable-next-line react-hooks/exhaustive-deps
[canInvoice, selected, selectedLineId],
);
return (
<Stack gap="lg">
<KpiStrip
loading={summaryLoading}
items={[
{
label: "Total outstanding",
value: summary
? formatMoney(summary.totalOutstanding, summary.currency)
: "—",
hint: "unbilled + billed",
icon: HandCoins,
color: "edr-green",
},
{
label: "Unbilled",
value: summary
? formatMoney(summary.unbilledAmount, summary.currency)
: "—",
hint: summary ? `${summary.unbilledCount} credits` : undefined,
icon: Clock,
color: "yellow",
},
{
label: "Billed",
value: summary
? formatMoney(summary.billedAmount, summary.currency)
: "—",
hint: summary ? `${summary.billedCount} on invoices` : undefined,
icon: Receipt,
color: "blue",
},
]}
/>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap">
<Group gap="sm" wrap="wrap">
<Select
placeholder="All shipping lines"
data={lineOptions}
value={shippingLineId}
onChange={(v) => {
setShippingLineId(v);
resetPage();
}}
clearable
searchable
radius="lg"
style={{ minWidth: 220 }}
/>
<Select
placeholder="All statuses"
data={STATUS_OPTIONS}
value={status}
onChange={(v) => {
setStatus((v as ShippingLineCreditStatus | null) ?? null);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 160 }}
/>
{activeFilterCount > 0 ? (
<Button
variant="subtle"
color="gray"
radius="lg"
leftSection={<FilterX size={16} />}
onClick={clearFilters}
>
Clear filters ({activeFilterCount})
</Button>
) : null}
</Group>
<Group gap="sm" wrap="nowrap">
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
<Button
variant="default"
size="compact-sm"
leftSection={<RefreshCw size={14} />}
loading={isFetching}
onClick={handleRefresh}
>
Refresh
</Button>
</Group>
</Group>
</Box>
{selectedCredits.length > 0 ? (
<>
<Divider />
<Group
px="md"
py="sm"
justify="space-between"
wrap="wrap"
bg="var(--mantine-color-edr-green-0)"
>
<Text size="sm" fw={600}>
{selectedCredits.length} credit
{selectedCredits.length === 1 ? "" : "s"} selected ·{" "}
{formatMoney(selectedTotal, selectedCredits[0].currency)}
{" — "}
{selectedCredits[0].shippingLineCompany?.name ?? ""}
</Text>
<Group gap="sm">
<Button
variant="subtle"
color="gray"
size="compact-sm"
onClick={clearSelection}
>
Clear selection
</Button>
<Button
size="compact-sm"
leftSection={<Receipt size={14} />}
onClick={() => setInvoiceOpen(true)}
>
Generate invoice
</Button>
</Group>
</Group>
</>
) : null}
<Box style={{ overflowX: "auto" }} w="100%">
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
emptyMessage="No credits match this filter."
error={
isError
? {
message: error?.message ?? "Failed to load credits.",
onRetry: () => void refetch(),
}
: undefined
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
</Stack>
</Card>
<Modal
opened={invoiceOpen}
onClose={() => setInvoiceOpen(false)}
title="Generate invoice"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
One invoice for{" "}
<Text component="span" fw={600} c="edr-text">
{selectedCredits[0]?.shippingLineCompany?.name ?? "this line"}
</Text>{" "}
billing the selected credits. The line pays it at any CBE channel
there is no payment window.
</Text>
<Stack gap={6}>
{selectedCredits.map((credit) => (
<Group key={credit.id} justify="space-between" wrap="nowrap">
<Text size="sm" truncate>
{credit.booking?.reference ?? credit.description ?? credit.id}
</Text>
<Text size="sm" fw={500} style={{ whiteSpace: "nowrap" }}>
{formatMoney(Number(credit.amount), credit.currency)}
</Text>
</Group>
))}
<Divider my={4} />
<Group justify="space-between">
<Text size="sm" fw={700}>
Total
</Text>
<Text size="sm" fw={700}>
{formatMoney(
selectedTotal,
selectedCredits[0]?.currency ?? "ETB",
)}
</Text>
</Group>
</Stack>
<NumberInput
label="Due in days"
description="Optional — defaults to the standard invoice term."
placeholder="14"
min={1}
value={dueInDays}
onChange={(v) => setDueInDays(typeof v === "number" ? v : "")}
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={() => setInvoiceOpen(false)}
disabled={isInvoicing}
>
Cancel
</Button>
<Button
loading={isInvoicing}
onClick={() =>
generateInvoice({
creditIds: selectedCredits.map((c) => c.id),
...(typeof dueInDays === "number"
? { dueInDays }
: {}),
})
}
>
Generate &amp; issue
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}

View File

@@ -50,6 +50,14 @@ import type {
RegisterShippingLineCompanyResult,
ShippingLineCompany,
} from "@/types/shippingLineCompany";
import type {
CreditInvoicePendingAction,
GeneratedCreditInvoice,
OutstandingTotals,
PaginatedCreditInvoices,
PaginatedShippingLineCredits,
ShippingLineCreditStatus,
} from "@/types/shippingLineCredit";
import {
RuleEngineListResult,
RuleEngineRecord,
@@ -163,6 +171,7 @@ import { containerTypesService } from "./container-types.service";
import { containerService, type Container } from "./containerService";
import { customersService } from "./customers.service";
import { shippingLineCompaniesService } from "./shippingLineCompanies.service";
import { shippingLineCreditsService } from "./shippingLineCredits.service";
import { eimsService } from "./eims.service";
import type { EimsInvoiceStatusView, EimsVerifyResult } from "@/types/eims";
import { invoicesService } from "./invoices.service";
@@ -2836,6 +2845,123 @@ export const api = {
),
},
shippingLineCredits: {
summary: endpoint<{ shippingLineId?: string }, OutstandingTotals>(
"shippingLineCredits",
"summary",
({ shippingLineId }) => shippingLineCreditsService.summary(shippingLineId),
({ shippingLineId }) =>
QUERY_KEYS.SHIPPING_LINE_CREDITS.summary(shippingLineId),
),
list: endpoint<
{
page: number;
pageSize: number;
status?: ShippingLineCreditStatus;
shippingLineId?: string;
},
PaginatedShippingLineCredits
>(
"shippingLineCredits",
"list",
(filter) => shippingLineCreditsService.list(filter),
({ page, pageSize, status, shippingLineId }) =>
QUERY_KEYS.SHIPPING_LINE_CREDITS.list(
page,
pageSize,
status,
shippingLineId,
),
),
generateInvoice: endpoint<
{ creditIds: string[]; dueInDays?: number },
GeneratedCreditInvoice
>(
"shippingLineCredits",
"generateInvoice",
({ creditIds, dueInDays }) =>
shippingLineCreditsService.generateInvoice(creditIds, dueInDays),
undefined,
// Billing a batch changes ledger rows, the summary totals and (via the
// draft invoice) the invoices list.
() => [QUERY_KEYS.SHIPPING_LINE_CREDITS.ROOT, QUERY_KEYS.INVOICES.ROOT],
),
listInvoices: endpoint<
{
page: number;
pageSize: number;
status?: string;
shippingLineId?: string;
},
PaginatedCreditInvoices
>(
"shippingLineCredits",
"listInvoices",
(filter) => shippingLineCreditsService.listInvoices(filter),
({ page, pageSize, status, shippingLineId }) =>
QUERY_KEYS.SHIPPING_LINE_CREDITS.invoices(
page,
pageSize,
status,
shippingLineId,
),
),
pendingInvoiceActions: endpoint<
{ invoiceIds: string[] },
CreditInvoicePendingAction[]
>(
"shippingLineCredits",
"pendingInvoiceActions",
({ invoiceIds }) =>
shippingLineCreditsService.pendingInvoiceActions(invoiceIds),
({ invoiceIds }) =>
[
"shipping-line-credits",
"pending-actions",
[...invoiceIds].sort().join(","),
] as const,
),
requestInvoiceAction: endpoint<
{
invoiceId: string;
action: "MARK_PAID" | "CANCEL";
reason: string;
paymentReference?: string;
},
CreditInvoicePendingAction
>(
"shippingLineCredits",
"requestInvoiceAction",
({ invoiceId, action, reason, paymentReference }) =>
shippingLineCreditsService.requestInvoiceAction(
invoiceId,
action,
reason,
paymentReference,
),
undefined,
() => [QUERY_KEYS.SHIPPING_LINE_CREDITS.ROOT],
),
decideInvoiceAction: endpoint<
{ approvalId: string; approve: boolean; note?: string },
CreditInvoicePendingAction
>(
"shippingLineCredits",
"decideInvoiceAction",
({ approvalId, approve, note }) =>
shippingLineCreditsService.decideInvoiceAction(approvalId, approve, note),
undefined,
// Approving executes a billing action, so both surfaces move.
() => [QUERY_KEYS.SHIPPING_LINE_CREDITS.ROOT, QUERY_KEYS.INVOICES.ROOT],
),
},
customers: {
stats: endpoint<Record<string, never>, CompanyStats>(
"customers",

View File

@@ -0,0 +1,131 @@
import { api as apiClient } from "@/auth/http";
import { URL_CONSTANTS } from "@/constants/URLS";
import type {
CreditInvoicePendingAction,
GeneratedCreditInvoice,
OutstandingTotals,
PaginatedCreditInvoices,
PaginatedShippingLineCredits,
ShippingLineCreditStatus,
} from "@/types/shippingLineCredit";
export interface ShippingLineCreditListFilter {
page?: number;
pageSize?: number;
status?: ShippingLineCreditStatus;
/** Narrow to one line; omit for all lines. */
shippingLineId?: string;
}
export const shippingLineCreditsService = {
/** Outstanding totals — every line, or one line when an id is given. */
summary(shippingLineId?: string): Promise<OutstandingTotals> {
return apiClient
.get<OutstandingTotals>(URL_CONSTANTS.SHIPPING_LINE_CREDITS.SUMMARY, {
params: shippingLineId ? { shippingLineId } : {},
})
.then((r) => r.data);
},
/** The whole credit ledger, newest first, optionally filtered. */
list(
filter: ShippingLineCreditListFilter = {},
): Promise<PaginatedShippingLineCredits> {
const { page = 1, pageSize = 20, status, shippingLineId } = filter;
return apiClient
.get<PaginatedShippingLineCredits>(
URL_CONSTANTS.SHIPPING_LINE_CREDITS.BASE,
{
params: {
page,
pageSize,
...(status ? { status } : {}),
...(shippingLineId ? { shippingLineId } : {}),
},
},
)
.then((r) => r.data);
},
/**
* Bill a batch of unbilled credits as one invoice. The API enforces that all
* credits belong to one shipping line and share one currency.
*/
generateInvoice(
creditIds: string[],
dueInDays?: number,
): Promise<GeneratedCreditInvoice> {
return apiClient
.post<GeneratedCreditInvoice>(URL_CONSTANTS.SHIPPING_LINE_CREDITS.INVOICE, {
creditIds,
...(dueInDays ? { dueInDays } : {}),
})
.then((r) => r.data);
},
/** Credit invoices with any pending manual-action request attached. */
listInvoices(filter: {
page?: number;
pageSize?: number;
status?: string;
shippingLineId?: string;
} = {}): Promise<PaginatedCreditInvoices> {
const { page = 1, pageSize = 20, status, shippingLineId } = filter;
return apiClient
.get<PaginatedCreditInvoices>(URL_CONSTANTS.SHIPPING_LINE_CREDITS.INVOICES, {
params: {
page,
pageSize,
...(status ? { status } : {}),
...(shippingLineId ? { shippingLineId } : {}),
},
})
.then((r) => r.data);
},
/** Undecided manual-action requests for a batch of invoice ids. */
pendingInvoiceActions(
invoiceIds: string[],
): Promise<CreditInvoicePendingAction[]> {
if (!invoiceIds.length) return Promise.resolve([]);
return apiClient
.get<CreditInvoicePendingAction[]>(
`${URL_CONSTANTS.SHIPPING_LINE_CREDITS.BASE}/invoice-actions/pending`,
{ params: { invoiceIds: invoiceIds.join(",") } },
)
.then((r) => r.data);
},
/** Maker step: raise a mark-paid or cancel request on a credit invoice. */
requestInvoiceAction(
invoiceId: string,
action: "MARK_PAID" | "CANCEL",
reason: string,
paymentReference?: string,
): Promise<CreditInvoicePendingAction> {
const url =
action === "MARK_PAID"
? URL_CONSTANTS.SHIPPING_LINE_CREDITS.MARK_PAID_REQUEST(invoiceId)
: URL_CONSTANTS.SHIPPING_LINE_CREDITS.CANCEL_REQUEST(invoiceId);
return apiClient
.post<CreditInvoicePendingAction>(url, {
reason,
...(paymentReference ? { paymentReference } : {}),
})
.then((r) => r.data);
},
/** Decision step: approve (executes) or reject a pending request. */
decideInvoiceAction(
approvalId: string,
approve: boolean,
note?: string,
): Promise<CreditInvoicePendingAction> {
const url = approve
? URL_CONSTANTS.SHIPPING_LINE_CREDITS.APPROVE_ACTION(approvalId)
: URL_CONSTANTS.SHIPPING_LINE_CREDITS.REJECT_ACTION(approvalId);
return apiClient
.post<CreditInvoicePendingAction>(url, note ? { note } : {})
.then((r) => r.data);
},
};

View File

@@ -90,6 +90,9 @@ export interface BookingContainerLine {
containerNumber?: string | null;
quantity: number;
vgmPerUnitTons: number;
/** How many of this line are hazardous / refrigerated — 0 when none. */
hazardousQuantity?: number;
reeferQuantity?: number;
containerType?: {
id: string;
code?: string;
@@ -199,6 +202,7 @@ export interface BookingDetail {
/** Break-bulk (PER_ITEM) only: real total tons — cargoTotalWeightVgm then holds the item count. */
bulkTotalWeightTons?: number | null;
isHazardous: boolean;
isReefer?: boolean;
consolidationPartnerId?: string | null;
consolidationPartner?: BookingNamedRef & { reference?: string } | null;
priorityScore: number;

View File

@@ -0,0 +1,96 @@
/**
* The credit ledger for shipping lines — "use the service now, pay later".
* Mirrors `shipping-line-credits` API responses.
*/
export type ShippingLineCreditStatus =
| "UNBILLED"
| "BILLED"
| "PAID"
| "CANCELLED";
export interface ShippingLineCredit {
id: string;
shippingLineCompanyId: string;
bookingId: string;
/** Numeric column — serialized as a string by the API. */
amount: string;
currency: string;
status: ShippingLineCreditStatus;
description: string | null;
invoiceId: string | null;
billedAt: string | null;
paidAt: string | null;
cancelledAt: string | null;
cancellationReason: string | null;
createdAt: string;
booking?: { id: string; reference: string } | null;
invoice?: { id: string; invoiceNumber: string } | null;
shippingLineCompany?: { id: string; name: string } | null;
}
/** What one shipping line currently owes, split by billing stage. */
export interface OutstandingTotals {
unbilledAmount: number;
billedAmount: number;
totalOutstanding: number;
unbilledCount: number;
billedCount: number;
currency: string;
}
export interface PaginatedShippingLineCredits {
items: ShippingLineCredit[];
total: number;
page: number;
pageSize: number;
}
/** The invoice minted from a batch of unbilled credits (subset of fields). */
export interface GeneratedCreditInvoice {
id: string;
invoiceNumber: string;
totalAmount: string | number;
currency: string;
status: string;
dueDate: string | null;
}
export type CreditInvoiceActionType = "MARK_PAID" | "CANCEL";
export type CreditInvoiceActionStatus = "PENDING" | "APPROVED" | "REJECTED";
/** An undecided manual-action request attached to a credit invoice. */
export interface CreditInvoicePendingAction {
id: string;
invoiceId: string;
action: CreditInvoiceActionType;
status: CreditInvoiceActionStatus;
requestedBy: string;
reason: string;
paymentReference: string | null;
createdAt: string;
}
/** A credit invoice row in the staff list, enriched by the API. */
export interface CreditInvoice {
id: string;
invoiceNumber: string;
status: string;
currency: string;
totalAmount: string | number;
paidAmount: string | number;
balanceAmount: string | number;
issuedAt: string | null;
dueAt: string | null;
createdAt: string;
shippingLineCompanyId: string | null;
shippingLineName: string | null;
pendingAction: CreditInvoicePendingAction | null;
}
export interface PaginatedCreditInvoices {
items: CreditInvoice[];
total: number;
page: number;
pageSize: number;
}

View File

@@ -62,6 +62,7 @@ import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
import {
ShippingLineBookingDetailPage,
ShippingLineBookingsPage,
ShippingLineCompletePage,
ShippingLineHelpPage,
ShippingLineHomePage,
ShippingLineInvoicesPage,
@@ -439,10 +440,21 @@ const App = () => {
path="/shipping-line/bookings/:id"
element={<ShippingLineBookingDetailPage />}
/>
<Route
path="/shipping-line/bookings/:id/complete"
element={<ShippingLineCompletePage />}
/>
<Route
path="/shipping-line/invoices"
element={<ShippingLineInvoicesPage />}
/>
{/* Same detail component as the customer's /billing/:id — the
API scopes my-invoices to the signed-in payer either way,
and the page derives its back target from the URL. */}
<Route
path="/shipping-line/invoices/:id"
element={<InvoiceDetailPage />}
/>
<Route
path="/shipping-line/settings"
element={<ShippingLineSettingsPage />}

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
Alert,
@@ -59,6 +59,11 @@ function MetaItem({ label, value }: { label: string; value: string }) {
export default function InvoiceDetailPage() {
const { id = "" } = useParams();
const navigate = useNavigate();
// Mounted at /billing/:id (customer) and /shipping-line/invoices/:id — the
// back target follows whichever list the reader came through.
const backHref = useLocation().pathname.startsWith("/shipping-line")
? "/shipping-line/invoices"
: "/billing";
const {
data: invoice,
isLoading,
@@ -87,7 +92,7 @@ export default function InvoiceDetailPage() {
variant="subtle"
color="gray"
leftSection={<ArrowLeft size={16} />}
onClick={() => navigate("/billing")}
onClick={() => navigate(backHref)}
mb="md"
>
Back to invoices
@@ -165,7 +170,7 @@ export default function InvoiceDetailPage() {
variant="subtle"
color="gray"
leftSection={<ArrowLeft size={16} />}
onClick={() => navigate("/billing")}
onClick={() => navigate(backHref)}
style={{ alignSelf: "flex-start" }}
styles={{ root: { fontWeight: 600 } }}
>

View File

@@ -7,6 +7,7 @@ import {
Loader,
Modal,
Stack,
Table,
Tabs,
Text,
Textarea,
@@ -19,10 +20,15 @@ import {
CheckCircle2,
ClipboardList,
Clock,
Container,
FileText,
Flame,
Package,
PackageCheck,
Snowflake,
Train,
Upload,
Wallet,
} from "lucide-react";
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
@@ -45,7 +51,6 @@ import {
DOC_STATE_COLOR,
DOC_STATE_LABEL,
} from "./booking-doc-state";
import ShippingLineCompleteModal from "./ShippingLineCompleteModal";
import ShippingLineDocumentsModal from "./ShippingLineDocumentsModal";
/**
@@ -71,7 +76,6 @@ export default function ShippingLineBookingDetailPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [docsOpen, setDocsOpen] = useState(false);
const [completeOpen, setCompleteOpen] = useState(false);
const [cancelOpen, setCancelOpen] = useState(false);
const [cancelReason, setCancelReason] = useState("");
@@ -89,6 +93,14 @@ export default function ShippingLineBookingDetailPage() {
queryFn: shippingLineBookingsService.myTrains,
});
// Operations view: the assigned/requested train and the wagons the batch
// engine allocated — feeds the Wagons & Train tab.
const operationsQuery = useQuery({
queryKey: ["shipping-line-bookings", id, "operations"],
queryFn: () => shippingLineBookingsService.operations(id),
enabled: Boolean(id),
});
const cancelMutation = useMutation({
mutationFn: () => shippingLineBookingsService.cancel(id, cancelReason),
onSuccess: () => {
@@ -138,6 +150,16 @@ export default function ShippingLineBookingDetailPage() {
const wantsUpload = needsUpload(docState);
const actionNeeded = docState === "ACTION_NEEDED";
const containers = booking.bookingContainers ?? [];
// Booking flag OR any line count — per-line opt-ins must light the badge
// even when the booking-level flag lags.
const isHazardous =
Boolean(booking.isHazardous) ||
containers.some((c) => Number(c.hazardousQuantity ?? 0) > 0);
const isReefer = containers.some((c) => Number(c.reeferQuantity ?? 0) > 0);
const breakdown = booking.pricingBreakdown ?? null;
const operations = operationsQuery.data ?? null;
// Mirrors the server's rule: cancellable only before the booking is priced.
// Kept in sync deliberately — a button the API would reject is worse than no
// button at all.
@@ -183,6 +205,28 @@ export default function ShippingLineBookingDetailPage() {
{DOC_STATE_LABEL[docState]}
</Badge>
)}
{isHazardous && (
<Badge
size="sm"
variant="filled"
radius="sm"
color="red"
leftSection={<Flame size={11} />}
>
Hazardous
</Badge>
)}
{isReefer && (
<Badge
size="sm"
variant="filled"
radius="sm"
color="blue"
leftSection={<Snowflake size={11} />}
>
Refrigerated
</Badge>
)}
</Group>
</Stack>
@@ -216,7 +260,9 @@ export default function ShippingLineBookingDetailPage() {
color="edr-green"
radius="md"
leftSection={<PackageCheck size={16} />}
onClick={() => setCompleteOpen(true)}
onClick={() =>
navigate(`/shipping-line/bookings/${booking.id}/complete`)
}
>
{status === "OPERATION_CHANGES_REQUESTED"
? "Resubmit booking"
@@ -226,6 +272,20 @@ export default function ShippingLineBookingDetailPage() {
</Group>
</Group>
{/* Operations' return note belongs at the top of the page — the line
must see what to fix without hunting through the tabs. */}
{status === "OPERATION_CHANGES_REQUESTED" && (
<Alert
color="orange"
radius="md"
icon={<AlertCircle size={18} />}
title="Operations requested changes"
>
{booking.operationChangeNote?.trim() ||
"Operations returned your booking request for changes. Resubmit it with an updated shipment day or cargo."}
</Alert>
)}
<Tabs defaultValue="documents" keepMounted={false}>
<Tabs.List>
<Tabs.Tab
@@ -245,6 +305,15 @@ export default function ShippingLineBookingDetailPage() {
<Tabs.Tab value="details" leftSection={<Package size={15} />}>
Booking details
</Tabs.Tab>
<Tabs.Tab value="cargo" leftSection={<Container size={15} />}>
Cargo
</Tabs.Tab>
<Tabs.Tab value="price" leftSection={<Wallet size={15} />}>
Price
</Tabs.Tab>
<Tabs.Tab value="operations" leftSection={<Train size={15} />}>
Wagons & Train
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="documents" pt="lg">
@@ -280,8 +349,8 @@ export default function ShippingLineBookingDetailPage() {
</Alert>
) : status === "OPERATION_CHANGES_REQUESTED" ? (
<Alert color="orange" radius="md" icon={<AlertCircle size={18} />}>
Operations returned your booking request for changes.
Resubmit it with an updated shipment day or cargo.
{booking.operationChangeNote?.trim() ||
"Operations returned your booking request for changes. Resubmit it with an updated shipment day or cargo."}
</Alert>
) : (
<Alert
@@ -313,7 +382,9 @@ export default function ShippingLineBookingDetailPage() {
color="edr-green"
radius="md"
leftSection={<PackageCheck size={16} />}
onClick={() => setCompleteOpen(true)}
onClick={() =>
navigate(`/shipping-line/bookings/${booking.id}/complete`)
}
>
{status === "OPERATION_CHANGES_REQUESTED"
? "Resubmit booking"
@@ -412,11 +483,333 @@ export default function ShippingLineBookingDetailPage() {
}`.trim()}
/>
)}
<DetailRow
label="Billing currency"
value={booking.paymentCurrency ?? "ETB"}
/>
{booking.cargoFreeText && (
<DetailRow
label="Cargo description"
value={booking.cargoFreeText}
/>
)}
</Stack>
</SectionCard>
}
/>
</Tabs.Panel>
<Tabs.Panel value="cargo" pt="lg">
<SectionCard>
<CardTitle>Cargo</CardTitle>
{containers.length === 0 &&
!(Number(booking.cargoTotalWeightVgm ?? 0) > 0) ? (
<Text fz={13} c="edr-muted" mt="sm">
No cargo entered yet it is added when you complete the
booking after your documents are approved.
</Text>
) : booking.freightType === "BULK" ? (
<Stack gap="xs" mt="sm">
<DetailRow
label="Cargo type"
value={booking.cargoType?.cargoTypeName ?? "—"}
/>
<DetailRow
label="Total weight"
value={`${Number(
booking.bulkTotalWeightTons ??
booking.cargoTotalWeightVgm ??
0,
).toLocaleString()} tons`}
/>
{booking.cargoFreeText && (
<DetailRow
label="Description"
value={booking.cargoFreeText}
/>
)}
</Stack>
) : (
<Stack gap="md" mt="sm">
<Table verticalSpacing="sm" horizontalSpacing="md">
<Table.Thead>
<Table.Tr>
<Table.Th>Container type</Table.Th>
<Table.Th ta="right">Qty</Table.Th>
<Table.Th ta="right">VGM / unit</Table.Th>
<Table.Th ta="right">Hazardous</Table.Th>
<Table.Th ta="right">Reefer</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{containers.map((line) => (
<Table.Tr key={line.id}>
<Table.Td>
<Text fw={600} fz={13}>
{line.containerType?.label ??
line.containerType?.code ??
line.containerSize ??
"—"}
</Text>
</Table.Td>
<Table.Td ta="right">{line.quantity}</Table.Td>
<Table.Td ta="right">
{Number(line.vgmPerUnitTons ?? 0)} t
</Table.Td>
<Table.Td ta="right">
{Number(line.hazardousQuantity ?? 0) > 0 ? (
<Text fw={700} c="red" fz={13}>
{line.hazardousQuantity}
</Text>
) : (
"—"
)}
</Table.Td>
<Table.Td ta="right">
{Number(line.reeferQuantity ?? 0) > 0 ? (
<Text fw={700} c="blue" fz={13}>
{line.reeferQuantity}
</Text>
) : (
"—"
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
{/* Per-container manifest, when the numbers were entered. */}
{containers.some((l) => (l.units?.length ?? 0) > 0) && (
<>
<CardTitle>Containers</CardTitle>
<Table verticalSpacing="xs" horizontalSpacing="md">
<Table.Thead>
<Table.Tr>
<Table.Th>Container no.</Table.Th>
<Table.Th>Seal</Table.Th>
<Table.Th ta="right">VGM</Table.Th>
<Table.Th>Handling</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{containers.flatMap((line) =>
(line.units ?? []).map((unit) => (
<Table.Tr key={unit.id}>
<Table.Td>
<Text fw={600} fz={13}>
{unit.containerNumber ?? "—"}
</Text>
</Table.Td>
<Table.Td>{unit.sealNumber ?? "—"}</Table.Td>
<Table.Td ta="right">
{Number(unit.vgmTons ?? 0)} t
</Table.Td>
<Table.Td>
<Group gap={4}>
{unit.isHazardous && (
<Badge size="xs" color="red" variant="filled">
Hazardous
</Badge>
)}
{unit.isReefer && (
<Badge size="xs" color="blue" variant="filled">
Reefer
</Badge>
)}
{!unit.isHazardous && !unit.isReefer && "—"}
</Group>
</Table.Td>
</Table.Tr>
)),
)}
</Table.Tbody>
</Table>
</>
)}
</Stack>
)}
</SectionCard>
</Tabs.Panel>
<Tabs.Panel value="price" pt="lg">
<SectionCard>
<CardTitle>Price</CardTitle>
{!breakdown?.lineItems?.length ? (
<Text fz={13} c="edr-muted" mt="sm">
No price yet the booking is priced when you complete it.
</Text>
) : (
<Stack gap="md" mt="sm">
<Table verticalSpacing="xs" horizontalSpacing="md">
<Table.Thead>
<Table.Tr>
<Table.Th>Charge</Table.Th>
<Table.Th ta="right">Qty</Table.Th>
<Table.Th ta="right">Amount</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{breakdown.lineItems.map((item, i) => (
<Table.Tr key={i}>
<Table.Td>
<Text fz={13}>{item.description}</Text>
</Table.Td>
<Table.Td ta="right">
<Text fz={13} c="edr-muted">
{item.quantity ?? 1}
</Text>
</Table.Td>
<Table.Td ta="right">
<Text fz={13} fw={600}>
{Number(item.amount).toLocaleString()}{" "}
{item.currency}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<Group
justify="space-between"
p="sm"
style={{
borderRadius: 10,
background: "var(--mantine-color-teal-0)",
}}
>
<Text fw={700}>Total (on credit)</Text>
<Text fw={800} fz={18}>
{Number(breakdown.totalAmount).toLocaleString()}{" "}
{breakdown.currency}
</Text>
</Group>
<Text fz={12} c="edr-muted">
Charged to your credit account EDR bills accumulated
charges periodically. Quoted{" "}
{new Date(breakdown.generatedAt).toLocaleString()}.
</Text>
</Stack>
)}
</SectionCard>
</Tabs.Panel>
<Tabs.Panel value="operations" pt="lg">
<Stack gap="lg">
<SectionCard>
<CardTitle>Train</CardTitle>
{operationsQuery.isLoading ? (
<Center py="lg">
<Loader size="sm" />
</Center>
) : !operations?.train ? (
<Text fz={13} c="edr-muted" mt="sm">
No train yet Operations assigns one after your booking is
reviewed and batched.
</Text>
) : (
<Stack gap="xs" mt="sm">
<Group gap={8}>
<Badge
size="sm"
radius="sm"
variant={operations.train.assigned ? "filled" : "light"}
color={operations.train.assigned ? "teal" : "yellow"}
>
{operations.train.assigned ? "Assigned" : "Requested"}
</Badge>
<StatusBadge status={operations.train.status} />
</Group>
<DetailRow
label="Train"
value={
operations.train.trainNumber ??
operations.train.reference ??
"—"
}
/>
<DetailRow
label="Route"
value={`${operations.train.originLabel}${operations.train.destinationLabel}`}
/>
<DetailRow
label="Departure"
value={new Date(
operations.train.scheduledDepartureDate,
).toLocaleString()}
/>
{operations.train.scheduledArrivalDate && (
<DetailRow
label="Arrival"
value={new Date(
operations.train.scheduledArrivalDate,
).toLocaleString()}
/>
)}
</Stack>
)}
</SectionCard>
<SectionCard>
<CardTitle>Wagons</CardTitle>
{operationsQuery.isLoading ? (
<Center py="lg">
<Loader size="sm" />
</Center>
) : !operations?.wagons?.length ? (
<Text fz={13} c="edr-muted" mt="sm">
No wagons allocated yet allocation happens once Operations
accepts your booking and builds the train.
</Text>
) : (
<Table
verticalSpacing="sm"
horizontalSpacing="md"
mt="sm"
highlightOnHover
>
<Table.Thead>
<Table.Tr>
<Table.Th>#</Table.Th>
<Table.Th>Wagon</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th ta="right">Loaded / capacity</Table.Th>
<Table.Th>Containers</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{operations.wagons.map((wagon) => (
<Table.Tr key={wagon.id}>
<Table.Td>{wagon.sequenceNo ?? "—"}</Table.Td>
<Table.Td>
<Text fw={600} fz={13}>
{wagon.wagonNumber ?? "To be assigned"}
</Text>
</Table.Td>
<Table.Td>{wagon.wagonType ?? "—"}</Table.Td>
<Table.Td ta="right">
{Number(wagon.allocatedWeightTons).toLocaleString()}{" "}
/ {Number(wagon.capacityTons).toLocaleString()} t
</Table.Td>
<Table.Td>
{wagon.containerNumbers.length
? wagon.containerNumbers.join(", ")
: "—"}
</Table.Td>
<Table.Td>
<Badge size="sm" radius="sm" variant="light">
{wagon.status}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</SectionCard>
</Stack>
</Tabs.Panel>
</Tabs>
<ShippingLineDocumentsModal
@@ -424,12 +817,6 @@ export default function ShippingLineBookingDetailPage() {
onClose={() => setDocsOpen(false)}
/>
<ShippingLineCompleteModal
booking={completeOpen ? booking : null}
onClose={() => setCompleteOpen(false)}
onCompleted={() => setCompleteOpen(false)}
/>
{/* Cancelling is irreversible, so it asks first rather than firing on the
button press. The reason is optional but recorded. */}
<Modal

View File

@@ -41,7 +41,6 @@ import {
DOC_STATE_COLOR,
DOC_STATE_LABEL,
} from "./booking-doc-state";
import ShippingLineCompleteModal from "./ShippingLineCompleteModal";
import ShippingLineDocumentsModal from "./ShippingLineDocumentsModal";
import ShippingLineInitiateModal from "./ShippingLineInitiateModal";
@@ -74,9 +73,6 @@ export default function ShippingLineBookingsPage() {
const [docsBooking, setDocsBooking] = useState<ShippingLineBooking | null>(
null,
);
const [bookBooking, setBookBooking] = useState<ShippingLineBooking | null>(
null,
);
const bookingsQuery = useQuery({
queryKey: ["shipping-line-bookings"],
@@ -183,7 +179,9 @@ export default function ShippingLineBookingsPage() {
fw={700}
fz={13}
leftSection={<PackageCheck size={14} />}
onClick={() => setBookBooking(booking)}
onClick={() =>
navigate(`/shipping-line/bookings/${booking.id}/complete`)
}
>
{booking.status === "OPERATION_CHANGES_REQUESTED"
? "Resubmit"
@@ -245,7 +243,9 @@ export default function ShippingLineBookingsPage() {
{bookable && (
<Menu.Item
leftSection={<PackageCheck size={15} />}
onClick={() => setBookBooking(booking)}
onClick={() =>
navigate(`/shipping-line/bookings/${booking.id}/complete`)
}
>
{booking.status === "OPERATION_CHANGES_REQUESTED"
? "Resubmit booking"
@@ -348,16 +348,6 @@ export default function ShippingLineBookingsPage() {
booking={docsBooking}
onClose={() => setDocsBooking(null)}
/>
<ShippingLineCompleteModal
booking={bookBooking}
onClose={() => setBookBooking(null)}
onCompleted={(booking) => {
setBookBooking(null);
// Straight into the booking — its status and price just changed.
navigate(`/shipping-line/bookings/${booking.id}`);
}}
/>
</Box>
);
}

View File

@@ -1,372 +0,0 @@
import {
ActionIcon,
Alert,
Box,
Button,
Center,
Group,
Loader,
Modal,
NumberInput,
Select,
Stack,
Text,
Textarea,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
CalendarDays,
PackageCheck,
Plus,
Trash2,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
shippingLineBookingsService,
type CompleteBookingContainerLine,
type ShippingLineBooking,
} from "@/services/shipping-line-bookings.service";
const CURRENCIES = [
{ value: "ETB", label: "ETB" },
{ value: "USD", label: "USD" },
];
interface ContainerLineDraft {
containerTypeId: string | null;
quantity: number | string;
vgmPerUnitTons: number | string;
}
const EMPTY_LINE: ContainerLineDraft = {
containerTypeId: null,
quantity: 1,
vgmPerUnitTons: 0,
};
/**
* Complete an approved shipping-line booking — the step a customer does after
* clearance: enter the cargo and the binding shipment day. The server prices
* the booking off the line's negotiated rates and puts the charge on the
* credit ledger (pay-later), so no payment step follows here.
*/
export default function ShippingLineCompleteModal({
booking,
onClose,
onCompleted,
}: {
/** The booking to complete, or null when the modal is closed. */
booking: ShippingLineBooking | null;
onClose: () => void;
onCompleted: (booking: ShippingLineBooking) => void;
}) {
const queryClient = useQueryClient();
const opened = Boolean(booking);
const bookingId = booking?.id ?? "";
const isContainer = (booking?.freightType ?? "CONTAINER") === "CONTAINER";
const [scheduledDate, setScheduledDate] = useState<string | null>(null);
const [currency, setCurrency] = useState<string>("ETB");
const [lines, setLines] = useState<ContainerLineDraft[]>([{ ...EMPTY_LINE }]);
const [cargoTypeId, setCargoTypeId] = useState<string | null>(null);
const [cargoWeightTons, setCargoWeightTons] = useState<number | string>(0);
const [cargoFreeText, setCargoFreeText] = useState("");
// Fresh sheet each open, prefilled with the day picked at initiate (if any).
useEffect(() => {
if (opened && booking) {
setScheduledDate(
booking.scheduledDate
? new Date(booking.scheduledDate).toISOString().slice(0, 10)
: null,
);
setCurrency(booking.paymentCurrency ?? "ETB");
setLines([{ ...EMPTY_LINE }]);
setCargoTypeId(null);
setCargoWeightTons(0);
setCargoFreeText("");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [opened, bookingId]);
const referenceQuery = useQuery({
queryKey: ["shipping-line-reference-data"],
queryFn: shippingLineBookingsService.referenceData,
enabled: opened,
});
// Only schedule-backed days are offered — same rule the server enforces.
const daysQuery = useQuery({
queryKey: ["shipping-line-bookings", bookingId, "available-days"],
queryFn: () => shippingLineBookingsService.availableDays(bookingId),
enabled: opened && Boolean(bookingId),
});
const completeMutation = useMutation({
mutationFn: () => {
const payload = isContainer
? {
scheduledDate: scheduledDate!,
paymentCurrency: currency,
cargoFreeText: cargoFreeText || undefined,
containers: lines
.filter((l) => l.containerTypeId && Number(l.quantity) > 0)
.map(
(l): CompleteBookingContainerLine => ({
containerTypeId: l.containerTypeId!,
quantity: Number(l.quantity),
vgmPerUnitTons: Number(l.vgmPerUnitTons) || 0,
}),
),
}
: {
scheduledDate: scheduledDate!,
paymentCurrency: currency,
cargoFreeText: cargoFreeText || undefined,
cargoTypeId: cargoTypeId!,
cargoWeightTons: Number(cargoWeightTons),
};
return shippingLineBookingsService.complete(bookingId, payload);
},
onSuccess: (updated) => {
void queryClient.invalidateQueries({
queryKey: ["shipping-line-bookings"],
});
onCompleted(updated);
},
});
const containerTypeOptions = useMemo(
() =>
(referenceQuery.data?.containerTypes ?? []).map((ct) => ({
value: ct.id,
label: ct.sizeFt ? `${ct.label} (${ct.sizeFt}ft)` : ct.label,
})),
[referenceQuery.data],
);
// Grouping headers are rows other rows point at via parentGroupId — only
// leaves are bookable cargo.
const cargoTypeOptions = useMemo(() => {
const all = referenceQuery.data?.cargoTypes ?? [];
const parents = new Set(
all.map((c) => c.parentGroupId).filter((id): id is string => Boolean(id)),
);
return all
.filter((c) => !parents.has(c.id))
.map((c) => ({ value: c.id, label: c.name }));
}, [referenceQuery.data]);
const dayOptions = useMemo(
() =>
(daysQuery.data?.days ?? []).map((day) => ({
value: day,
label: new Date(day).toLocaleDateString(undefined, {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
}),
})),
[daysQuery.data],
);
const validCargo = isContainer
? lines.some((l) => l.containerTypeId && Number(l.quantity) > 0)
: Boolean(cargoTypeId) && Number(cargoWeightTons) > 0;
const canSubmit = Boolean(scheduledDate) && validCargo;
const loading = referenceQuery.isLoading || daysQuery.isLoading;
return (
<Modal
opened={opened}
onClose={onClose}
centered
size="lg"
radius="md"
title={
<Box>
<Text fw={700} fz={16}>
Complete booking
</Text>
<Text fz={12} c="dimmed">
Your documents are approved enter the cargo and shipment day. The
charge goes on your credit account.
</Text>
</Box>
}
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
>
{loading ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : (
<Stack gap="md">
{dayOptions.length === 0 ? (
<Alert color="yellow" radius="md" icon={<AlertCircle size={16} />}>
No departures are currently open on this route. Please check back
or contact Operations.
</Alert>
) : (
<Select
label="Shipment day"
description="Only days with an open train departure on your route are offered."
placeholder="Pick the shipment day"
withAsterisk
searchable
leftSection={<CalendarDays size={16} />}
data={dayOptions}
value={scheduledDate}
onChange={setScheduledDate}
comboboxProps={{ withinPortal: true }}
/>
)}
{isContainer ? (
<Stack gap="xs">
<Text fz={13} fw={600}>
Containers
</Text>
{lines.map((line, index) => (
<Group key={index} gap="xs" align="flex-end" wrap="nowrap">
<Select
label={index === 0 ? "Container type" : undefined}
placeholder="Type"
searchable
style={{ flex: 2 }}
data={containerTypeOptions}
value={line.containerTypeId}
onChange={(v) =>
setLines((prev) =>
prev.map((l, i) =>
i === index ? { ...l, containerTypeId: v } : l,
),
)
}
comboboxProps={{ withinPortal: true }}
/>
<NumberInput
label={index === 0 ? "Quantity" : undefined}
min={1}
style={{ flex: 1 }}
value={line.quantity}
onChange={(v) =>
setLines((prev) =>
prev.map((l, i) =>
i === index ? { ...l, quantity: v } : l,
),
)
}
/>
<NumberInput
label={index === 0 ? "VGM / unit (tons)" : undefined}
min={0}
decimalScale={3}
style={{ flex: 1 }}
value={line.vgmPerUnitTons}
onChange={(v) =>
setLines((prev) =>
prev.map((l, i) =>
i === index ? { ...l, vgmPerUnitTons: v } : l,
),
)
}
/>
<ActionIcon
variant="subtle"
color="red"
size="lg"
disabled={lines.length === 1}
onClick={() =>
setLines((prev) => prev.filter((_, i) => i !== index))
}
aria-label="Remove line"
>
<Trash2 size={16} />
</ActionIcon>
</Group>
))}
<Button
variant="light"
size="compact-sm"
w="fit-content"
leftSection={<Plus size={14} />}
onClick={() => setLines((prev) => [...prev, { ...EMPTY_LINE }])}
>
Add container line
</Button>
</Stack>
) : (
<Group grow align="flex-start" gap="sm">
<Select
label="Cargo type"
placeholder="Select cargo..."
withAsterisk
searchable
data={cargoTypeOptions}
value={cargoTypeId}
onChange={setCargoTypeId}
comboboxProps={{ withinPortal: true }}
/>
<NumberInput
label="Total weight (tons)"
withAsterisk
min={0}
decimalScale={3}
value={cargoWeightTons}
onChange={setCargoWeightTons}
/>
</Group>
)}
<Group grow align="flex-start" gap="sm">
<Select
label="Billing currency"
data={CURRENCIES}
value={currency}
onChange={(v) => setCurrency(v ?? "ETB")}
comboboxProps={{ withinPortal: true }}
/>
<Textarea
label="Cargo description (optional)"
placeholder="What the shipment carries"
autosize
minRows={1}
maxRows={3}
maxLength={500}
value={cargoFreeText}
onChange={(e) => setCargoFreeText(e.currentTarget.value)}
/>
</Group>
{completeMutation.isError && (
<Alert color="red" icon={<AlertCircle size={16} />}>
{(completeMutation.error as Error)?.message ??
"Could not complete the booking."}
</Alert>
)}
<Group justify="flex-end" mt="sm" gap="sm">
<Button variant="default" radius="md" onClick={onClose}>
Cancel
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<PackageCheck size={16} />}
loading={completeMutation.isPending}
disabled={!canSubmit}
onClick={() => completeMutation.mutate()}
>
Complete booking
</Button>
</Group>
</Stack>
)}
</Modal>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,129 +1,795 @@
import {
Badge,
Card,
Center,
Box,
Button,
Grid,
Group,
Loader,
Skeleton,
Stack,
Text,
Title,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { ArrowRight, CalendarClock, TrainFront } from "lucide-react";
import {
AlertTriangle,
ArrowRight,
CalendarClock,
Clock3,
FileText,
FileUp,
LifeBuoy,
MapPin,
Package,
PackageCheck,
PackagePlus,
Receipt,
RefreshCw,
TrainFront,
Upload,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import useAuth from "@/hooks/useAuth";
import { BookingStatusBadge as StatusBadge } from "@/pages/bookings/booking-display";
import { Card, StatKpi } from "@/pages/MyPortalPage/components";
import { cv } from "@/pages/MyPortalPage/constants";
import {
shippingLineBookingsService,
type ShippingLineBooking,
type ShippingLineTrain,
} from "@/services/shipping-line-bookings.service";
const STATUS_COLOR: Record<string, string> = {
DRAFT: "gray",
SCHEDULED: "blue",
DISPATCHED: "teal",
};
import ShippingLineInitiateModal from "./ShippingLineInitiateModal";
function TrainCard({ train }: { train: ShippingLineTrain }) {
const departure = new Date(train.scheduledDepartureDate);
return (
<Card withBorder radius="md" p="md">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
<Stack gap={4}>
<Group gap="xs">
<TrainFront size={16} />
<Text fw={600} fz={14}>
{train.trainNumber ?? train.reference ?? "Train"}
</Text>
{train.reference && train.trainNumber ? (
<Text fz={12} c="dimmed">
{train.reference}
</Text>
) : null}
<Badge
size="sm"
variant="light"
color={STATUS_COLOR[train.status] ?? "gray"}
>
{train.status}
</Badge>
</Group>
<Group gap={6}>
<Text fz={13}>{train.originLabel}</Text>
<ArrowRight size={13} />
<Text fz={13}>{train.destinationLabel}</Text>
</Group>
</Stack>
<Stack gap={2} align="flex-end">
<Group gap={6}>
<CalendarClock size={14} />
<Text fz={13} fw={500}>
{departure.toLocaleDateString(undefined, {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
})}
</Text>
</Group>
<Text fz={12} c="dimmed">
Departs{" "}
{departure.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
})}
</Text>
</Stack>
</Group>
</Card>
);
/* ── Pending-action derivation ─────────────────────────────────────────────
* One row per booking that is waiting on the LINE (never on EDR). Priority:
* a queried/returned document set outranks everything — it blocks the rest. */
type ActionKind = "fix" | "resubmit" | "book" | "upload";
interface PendingAction {
kind: ActionKind;
booking: ShippingLineBooking;
urgent: boolean;
description: string;
}
const ACTION_META: Record<
ActionKind,
{ icon: typeof FileUp; color: string; button: string }
> = {
fix: { icon: FileUp, color: "red", button: "Fix documents" },
resubmit: { icon: RefreshCw, color: "orange", button: "Resubmit" },
book: { icon: PackagePlus, color: "violet", button: "Book" },
upload: { icon: Upload, color: "blue", button: "Upload documents" },
};
function deriveActions(bookings: ShippingLineBooking[]): PendingAction[] {
const actions: PendingAction[] = [];
for (const b of bookings) {
const status = b.status as string;
if (status === "CANCELLED") continue;
if (b.hasQueriedDocuments || status === "CHANGES_REQUESTED") {
actions.push({
kind: "fix",
booking: b,
urgent: true,
description: "A reviewer queried your documents — fix and re-upload.",
});
} else if (status === "OPERATION_CHANGES_REQUESTED") {
actions.push({
kind: "resubmit",
booking: b,
urgent: true,
description:
"Operations returned your request — update it and resubmit.",
});
} else if (status === "CLEARANCE_READY") {
actions.push({
kind: "book",
booking: b,
urgent: false,
description:
"Documents approved — enter the cargo and shipment day to book.",
});
} else if (status === "AWAITING_DOCUMENTS") {
actions.push({
kind: "upload",
booking: b,
urgent: false,
description: "Upload your documents to start the review.",
});
}
}
return actions.sort((a, b) => Number(b.urgent) - Number(a.urgent));
}
const laneLabel = (b: ShippingLineBooking) =>
`${b.originYard?.label ?? b.originYard?.code ?? "—"}${
b.destinationYard?.label ?? b.destinationYard?.code ?? "—"
}`;
const timeGreeting = () => {
const h = new Date().getHours();
if (h < 12) return "Good morning";
if (h < 18) return "Good afternoon";
return "Good evening";
};
/** Whole days from now to `date`, floored at 0 (today). */
const daysUntil = (date: string) =>
Math.max(
0,
Math.ceil((new Date(date).getTime() - Date.now()) / 86_400_000),
);
/* ── Page ──────────────────────────────────────────────────────────────── */
/**
* Shipping-line home / dashboard. Deliberately separate from the customer
* dashboard (`MyPortalPage`): shipping lines have no company, no operational
* profiles and no contracts, so almost none of that page's data applies.
*
* Lists the train departures dedicated to this shipping line — those trains
* are hidden from customers, so this page (and the booking detail's lane/day
* match) is where the line sees them.
* Shipping-line home. Mirrors the customer dashboard's anatomy — greeting +
* primary CTA, KPI band, "needs your attention" queue — but its centrepiece is
* the departure board: the line's next dedicated train, which no customer ever
* sees, so this page is where it must feel real.
*/
export default function ShippingLineHomePage() {
const navigate = useNavigate();
const { shippingLine } = useAuth();
// The hook's union type collapses on direct property access; the account is
// positively a shipping line on these routes, so the cast is safe.
const lineName =
(shippingLine as { name?: string } | null)?.name ?? "Shipping line";
const [initiateOpen, setInitiateOpen] = useState(false);
const bookingsQuery = useQuery({
queryKey: ["shipping-line-bookings"],
queryFn: shippingLineBookingsService.list,
});
const trainsQuery = useQuery({
queryKey: ["shipping-line-my-trains"],
queryFn: shippingLineBookingsService.myTrains,
});
const bookings = useMemo(
() => bookingsQuery.data ?? [],
[bookingsQuery.data],
);
const trains = trainsQuery.data ?? [];
const actions = useMemo(() => deriveActions(bookings), [bookings]);
const activeBookings = bookings.filter(
(b) => (b.status as string) !== "CANCELLED",
);
const inReview = bookings.filter(
(b) =>
!b.hasQueriedDocuments &&
["DOCUMENTS_UNDER_REVIEW", "OPERATION_REQUEST_PENDING"].includes(
b.status as string,
),
);
const upcoming = trains.filter(
(t) => new Date(t.scheduledDepartureDate).getTime() > Date.now() - 3_600_000,
);
const nextTrain = upcoming[0] ?? null;
const laterTrains = nextTrain ? upcoming.slice(1) : upcoming;
// Trains this line has BOOKED: allocated ones by trainScheduleId, plus —
// before allocation lands — any live booking on the same lane + shipment
// day. The hero shows EVERY booked departure of the next booked day, not
// just the first train.
const isSameDay = (a: string | Date, b: string | Date) =>
new Date(a).toDateString() === new Date(b).toDateString();
const bookedUpcoming = upcoming.filter((t) =>
activeBookings.some(
(b) =>
b.trainScheduleId === t.id ||
(b.scheduledDate &&
isSameDay(b.scheduledDate, t.scheduledDepartureDate) &&
b.originYard?.id === t.originYardId &&
b.destinationYard?.id === t.destinationYardId),
),
);
const nextBookedDay = bookedUpcoming[0]
? new Date(bookedUpcoming[0].scheduledDepartureDate).toDateString()
: null;
const nextDayDepartures = nextBookedDay
? bookedUpcoming.filter((t) =>
isSameDay(t.scheduledDepartureDate, nextBookedDay),
)
: [];
// Booked departures take the hero; with none, fall back to the line's next
// dedicated train so the board never goes blank while trains exist.
const heroTrains = nextDayDepartures.length
? nextDayDepartures
: nextTrain
? [nextTrain]
: [];
const heroIsBooked = nextDayDepartures.length > 0;
const heroFirst = heroTrains[0] ?? null;
const goToAction = (a: PendingAction) =>
navigate(
a.kind === "book" || a.kind === "resubmit"
? `/shipping-line/bookings/${a.booking.id}/complete`
: `/shipping-line/bookings/${a.booking.id}`,
);
return (
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
<Stack gap={4}>
<Title order={2}>Home</Title>
<Text c="dimmed" size="sm">
Overview of your shipping-line activity.
</Text>
</Stack>
<Stack gap="sm">
<Text fw={600} fz={15}>
Your trains
</Text>
{trainsQuery.isLoading ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : trains.length === 0 ? (
<Card withBorder radius="md" py={48}>
<Stack align="center" gap="xs">
<TrainFront size={28} className="text-slate-300" />
<Text c="dimmed" size="sm">
No trains have been assigned to you yet.
{/* Greeting + the one primary action, same shape as the customer home. */}
<Group justify="space-between" align="center" gap="md">
<Box>
<Text size="sm" c="edr-muted">
{timeGreeting()}
</Text>
<Text fz={26} fw={800} c="edr-text" className="tracking-tight" mt={2}>
{lineName}
</Text>
</Box>
<Box
component="button"
onClick={() => setInitiateOpen(true)}
className="w-full cursor-pointer border-none bg-transparent p-0 text-left md:w-auto"
>
<Group
gap={14}
align="center"
wrap="nowrap"
bg="edr-green"
px={18}
py={14}
className="w-full md:w-60! rounded-2xl shadow-[0_6px_10px_-12px_rgba(14,163,83,0.8)]"
>
<PackagePlus size={22} color="#fff" />
<Box className="min-w-0 flex-1">
<Text fz={14} fw={700} c="white" lh={1.3}>
Initiate a booking
</Text>
</Box>
<Box className="flex h-[32px] w-[32px] shrink-0 items-center justify-center rounded-full bg-white">
<ArrowRight size={18} color={cv("edr-green.7")} />
</Box>
</Group>
</Box>
</Group>
{/* KPI band — one card, four figures that answer "where do I stand". */}
<Card>
<Box className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4 lg:gap-4">
<StatKpi
icon={Package}
label="Active bookings"
value={String(activeBookings.length)}
delta=""
accent="green"
loading={bookingsQuery.isPending}
/>
<StatKpi
icon={AlertTriangle}
label="Waiting on you"
value={String(actions.length)}
delta={actions.length > 0 ? "action needed" : "all clear"}
accent="amber"
deltaTone={actions.length > 0 ? "amber" : "muted"}
divider
loading={bookingsQuery.isPending}
/>
<StatKpi
icon={Clock3}
label="In review with EDR"
value={String(inReview.length)}
delta=""
accent="blue"
divider
loading={bookingsQuery.isPending}
/>
<StatKpi
icon={TrainFront}
label="Upcoming trains"
value={String(upcoming.length)}
delta=""
accent="slate"
divider
loading={trainsQuery.isPending}
/>
</Box>
</Card>
{/* Bookings waiting on the line — urgent first, one click to the fix. */}
{actions.length > 0 && (
<Card padding={0}>
<Group justify="space-between" align="center" px={24} pt={20} pb={12}>
<Group gap={8}>
<AlertTriangle size={18} className="text-amber-500" />
<Text fw={700} fz={16} c="edr-text">
Needs your attention
</Text>
</Group>
<Badge color="orange" variant="light" radius="sm">
{actions.length}
</Badge>
</Group>
<Stack gap={0}>
{actions.map((a, i) => {
const meta = ACTION_META[a.kind];
const Icon = meta.icon;
return (
<Group
key={a.booking.id}
justify="space-between"
wrap="nowrap"
px={24}
py={14}
style={{
borderTop:
i === 0 ? "none" : "1px solid var(--mantine-color-gray-2)",
cursor: "pointer",
}}
onClick={() => goToAction(a)}
className="hover:bg-edr-soft"
>
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<Box
style={{
width: 36,
height: 36,
flexShrink: 0,
borderRadius: 10,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: `var(--mantine-color-${meta.color}-light)`,
color: `var(--mantine-color-${meta.color}-filled)`,
}}
>
<Icon size={18} />
</Box>
<Box style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Text fw={600} fz={14} c="edr-text" truncate>
{a.booking.reference}
</Text>
{a.urgent && (
<Badge
size="xs"
color="red"
variant="light"
radius="sm"
>
Action required
</Badge>
)}
</Group>
<Text fz={12.5} c="dimmed" truncate>
{laneLabel(a.booking)} · {a.description}
</Text>
</Box>
</Group>
<Button
size="compact-sm"
variant={a.urgent ? "filled" : "light"}
color={meta.color}
radius="md"
>
{meta.button}
</Button>
</Group>
);
})}
</Stack>
</Card>
)}
{/* The departure board — every departure the line has BOOKED on its
next booked day (falling back to the next dedicated train when
nothing is booked yet). These trains are invisible to customers, so
this is the one place they surface; give them real presence. */}
{heroFirst && (
<Box
className="rounded-[20px]"
p={{ base: 20, sm: 28 }}
style={{
background: `linear-gradient(120deg, ${cv("edr-green.9")} 0%, ${cv(
"edr-green.7",
)} 60%, ${cv("edr-green.6")} 100%)`,
color: "#fff",
}}
>
<Group justify="space-between" align="flex-end" wrap="wrap" gap="lg">
<Box>
<Group gap={8} mb={6}>
<TrainFront size={16} color="rgba(255,255,255,0.85)" />
<Text
fz={11}
fw={700}
tt="uppercase"
style={{ letterSpacing: "0.12em", color: "rgba(255,255,255,0.85)" }}
>
{heroIsBooked
? heroTrains.length > 1
? "Your booked departures"
: "Your next departure"
: "Your next departure"}
</Text>
{heroIsBooked && (
<Badge
size="sm"
radius="sm"
variant="white"
color="edr-green"
fw={700}
>
{heroTrains.length > 1
? `${heroTrains.length} trains`
: "Booked"}
</Badge>
)}
</Group>
<Text fz={{ base: 28, sm: 34 }} fw={800} lh={1.1} c="white">
{new Date(heroFirst.scheduledDepartureDate).toLocaleDateString(
undefined,
{ weekday: "long", month: "long", day: "numeric" },
)}
</Text>
</Box>
<Stack gap={10} align="flex-end">
<Group
gap={8}
px={14}
py={8}
className="rounded-full"
style={{ background: "rgba(255,255,255,0.16)" }}
>
<CalendarClock size={15} color="#fff" />
<Text fz={13} fw={700} c="white">
{daysUntil(heroFirst.scheduledDepartureDate) === 0
? "Departs today"
: daysUntil(heroFirst.scheduledDepartureDate) === 1
? "Departs tomorrow"
: `Departs in ${daysUntil(
heroFirst.scheduledDepartureDate,
)} days`}
</Text>
</Group>
{!heroIsBooked && (
<Text fz={11.5} style={{ color: "rgba(255,255,255,0.75)" }}>
Departs{" "}
{new Date(
heroFirst.scheduledDepartureDate,
).toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
})}{" "}
· bookable until the cut-off before departure
</Text>
)}
</Stack>
</Group>
{/* Every departure of that day — one row per train. */}
<Stack gap={8} mt={16}>
{heroTrains.map((t) => (
<Group
key={t.id}
justify="space-between"
wrap="wrap"
gap="sm"
px={14}
py={10}
className="rounded-xl"
style={{ background: "rgba(255,255,255,0.12)" }}
>
<Group gap={10} wrap="nowrap">
<MapPin size={15} color="rgba(255,255,255,0.85)" />
<Text fz={14} fw={600} style={{ color: "rgba(255,255,255,0.95)" }}>
{t.originLabel} {t.destinationLabel}
{t.trainNumber
? ` · Train ${t.trainNumber}`
: t.reference
? ` · ${t.reference}`
: ""}
</Text>
</Group>
<Group gap={10}>
<Text fz={13} fw={700} c="white">
{new Date(t.scheduledDepartureDate).toLocaleTimeString(
undefined,
{ hour: "2-digit", minute: "2-digit" },
)}
</Text>
<Badge
size="sm"
radius="sm"
variant="white"
color="edr-green"
fw={700}
>
{t.status}
</Badge>
</Group>
</Group>
))}
</Stack>
</Box>
)}
{/* Trains + recent bookings, side by side. */}
<Grid align="stretch">
<Grid.Col span={{ base: 12, lg: 5 }}>
<Card padding={0} className="h-full">
<Group justify="space-between" px={24} pt={20} pb={12}>
<Group gap={8}>
<TrainFront size={17} color={cv("edr-green.7")} />
<Text fw={700} fz={16} c="edr-text">
Your trains
</Text>
</Group>
<Badge variant="light" color="gray" radius="sm">
{upcoming.length}
</Badge>
</Group>
{trainsQuery.isPending ? (
<Stack gap={10} px={24} pb={20}>
<Skeleton height={54} radius="md" />
<Skeleton height={54} radius="md" />
</Stack>
) : upcoming.length === 0 ? (
<TrainsEmpty />
) : (
<Stack gap={0} pb={8}>
{(nextTrain ? [nextTrain, ...laterTrains] : laterTrains).map(
(t, i) => (
<TrainRow key={t.id} train={t} first={i === 0} />
),
)}
</Stack>
)}
</Card>
) : (
trains.map((train) => <TrainCard key={train.id} train={train} />)
)}
</Stack>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 7 }}>
<Card padding={0} className="h-full">
<Group justify="space-between" px={24} pt={20} pb={12}>
<Group gap={8}>
<FileText size={17} color={cv("edr-blue")} />
<Text fw={700} fz={16} c="edr-text">
Recent bookings
</Text>
</Group>
<Button
variant="subtle"
color="gray"
size="compact-sm"
rightSection={<ArrowRight size={14} />}
onClick={() => navigate("/shipping-line/bookings")}
>
View all
</Button>
</Group>
{bookingsQuery.isPending ? (
<Stack gap={10} px={24} pb={20}>
<Skeleton height={54} radius="md" />
<Skeleton height={54} radius="md" />
<Skeleton height={54} radius="md" />
</Stack>
) : bookings.length === 0 ? (
<BookingsEmpty onInitiate={() => setInitiateOpen(true)} />
) : (
<Stack gap={0} pb={8}>
{bookings.slice(0, 6).map((b, i) => (
<Group
key={b.id}
justify="space-between"
wrap="nowrap"
px={24}
py={12}
style={{
borderTop:
i === 0
? "none"
: "1px solid var(--mantine-color-gray-2)",
cursor: "pointer",
}}
className="hover:bg-edr-soft"
onClick={() => navigate(`/shipping-line/bookings/${b.id}`)}
>
<Box style={{ minWidth: 0 }}>
<Text fw={600} fz={14} c="edr-text" truncate>
{b.reference}
</Text>
<Text fz={12.5} c="dimmed" truncate>
{laneLabel(b)}
{b.scheduledDate
? ` · ships ${new Date(
b.scheduledDate,
).toLocaleDateString()}`
: ""}
</Text>
</Box>
<StatusBadge status={b.status as string} />
</Group>
))}
</Stack>
)}
</Card>
</Grid.Col>
</Grid>
{/* Quick links — the rest of the portal, one hop away. */}
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<QuickLink
icon={PackageCheck}
title="My bookings"
hint="Track every booking and its documents"
onClick={() => navigate("/shipping-line/bookings")}
/>
<QuickLink
icon={Receipt}
title="Invoices"
hint="Charges on your credit account"
onClick={() => navigate("/shipping-line/invoices")}
/>
<QuickLink
icon={LifeBuoy}
title="Help & support"
hint="Guides and contact channels"
onClick={() => navigate("/shipping-line/help")}
/>
</Box>
<ShippingLineInitiateModal
opened={initiateOpen}
onClose={() => setInitiateOpen(false)}
onCreated={(booking) => {
setInitiateOpen(false);
navigate(`/shipping-line/bookings/${booking.id}`);
}}
/>
</Stack>
);
}
/* ── Small pieces ──────────────────────────────────────────────────────── */
function TrainRow({
train,
first,
}: {
train: ShippingLineTrain;
first: boolean;
}) {
const departure = new Date(train.scheduledDepartureDate);
return (
<Group
justify="space-between"
wrap="nowrap"
px={24}
py={12}
style={{
borderTop: first ? "none" : "1px solid var(--mantine-color-gray-2)",
}}
>
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<Box
style={{
width: 36,
height: 36,
flexShrink: 0,
borderRadius: 10,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: cv("edr-soft"),
color: cv("edr-green.7"),
}}
>
<TrainFront size={18} />
</Box>
<Box style={{ minWidth: 0 }}>
<Text fw={600} fz={14} c="edr-text" truncate>
{train.trainNumber ?? train.reference ?? "Train"}
</Text>
<Text fz={12.5} c="dimmed" truncate>
{train.originLabel} {train.destinationLabel}
</Text>
</Box>
</Group>
<Box style={{ textAlign: "right", flexShrink: 0 }}>
<Text fz={13} fw={600} c="edr-text">
{departure.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
})}
</Text>
<Text fz={11.5} c="dimmed">
{departure.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
})}
</Text>
</Box>
</Group>
);
}
function TrainsEmpty() {
return (
<Stack align="center" gap={6} py={36} px={24}>
<TrainFront size={26} color={cv("edr-slate")} />
<Text fz={13.5} c="dimmed" ta="center">
No trains assigned to you yet Operations dedicates departures to your
line and they appear here.
</Text>
</Stack>
);
}
function BookingsEmpty({ onInitiate }: { onInitiate: () => void }) {
return (
<Stack align="center" gap={10} py={32} px={24}>
<Package size={26} color={cv("edr-slate")} />
<Text fz={13.5} c="dimmed" ta="center">
No bookings yet. Initiate one you upload documents next, and book the
cargo once they are approved.
</Text>
<Button
color="edr-green"
radius="md"
size="compact-md"
leftSection={<PackagePlus size={15} />}
onClick={onInitiate}
>
Initiate a booking
</Button>
</Stack>
);
}
function QuickLink({
icon: Icon,
title,
hint,
onClick,
}: {
icon: typeof Receipt;
title: string;
hint: string;
onClick: () => void;
}) {
return (
<Box
component="button"
onClick={onClick}
className="cursor-pointer rounded-[16px] border border-edr-border bg-edr-card p-4 text-left transition-colors hover:bg-edr-soft"
>
<Group gap={12} wrap="nowrap">
<Box
style={{
width: 38,
height: 38,
flexShrink: 0,
borderRadius: 11,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: cv("edr-soft"),
color: cv("edr-green.7"),
}}
>
<Icon size={18} />
</Box>
<Box style={{ minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text fw={700} fz={14} c="edr-text">
{title}
</Text>
<ArrowRight size={14} color={cv("edr-muted")} />
</Group>
<Text fz={12} c="dimmed" truncate>
{hint}
</Text>
</Box>
</Group>
</Box>
);
}

View File

@@ -1,13 +1,231 @@
import { Receipt } from "lucide-react";
import ShippingLinePlaceholder from "./ShippingLinePlaceholder";
import {
Alert,
Box,
Button,
Card,
Center,
Group,
Loader,
SimpleGrid,
Stack,
Table,
Text,
Title,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { Freight } from "@edr/types";
import { ChevronRight, Info, Receipt } from "lucide-react";
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
/** Shipping-line payments / invoices. */
export default function ShippingLineInvoicesPage() {
import { formatCurrency } from "@/lib/currency";
import { api } from "@/services/api";
import { BORDER, INK, MUTED } from "../contracts/contract-ui";
import { fmtDate, InvoiceStatusBadge, isPayable } from "../billing/invoice-ui";
function StatBox({ label, value }: { label: string; value: string }) {
return (
<ShippingLinePlaceholder
title="Payments"
description="Your invoices and payment history."
icon={<Receipt size={28} className="text-slate-300" />}
/>
<Card withBorder radius="md" padding="md">
<Text
fz={11}
fw={700}
c={MUTED}
style={{ textTransform: "uppercase", letterSpacing: "0.05em" }}
>
{label}
</Text>
<Text fz={22} fw={800} mt={4} style={{ color: INK }}>
{value}
</Text>
</Card>
);
}
/**
* The shipping line's credit invoices — Finance bills batches of the line's
* booking charges, and each invoice is payable at any CBE channel whenever the
* line chooses; there is no payment window. Detail (which bookings, each
* charge, the total) lives one click deeper on the shared invoice detail page.
*/
export default function ShippingLineInvoicesPage() {
const navigate = useNavigate();
const {
data: allInvoices,
isLoading,
isError,
refetch,
} = useQuery(api.invoices.listMy.queryOptions());
// Only the credit invoices: a shipping line's bookings are charged through
// the credit ledger, so other sources (e.g. a booking's internal draft
// invoice) would only double-state the same debt here.
const invoices = useMemo(
() =>
(allInvoices ?? []).filter(
(inv) => inv.source === Freight.InvoiceSource.ShippingLineCredit,
),
[allInvoices],
);
const openInvoices = invoices.filter((inv) => isPayable(inv.status));
const outstanding = openInvoices.reduce(
(sum, inv) => sum + Number(inv.balanceAmount ?? inv.totalAmount),
0,
);
const currency = invoices[0]?.currency ?? "ETB";
return (
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
<Stack gap={4}>
<Title order={2}>Invoices</Title>
<Text c="dimmed" size="sm">
Your billed freight charges. Pay any open invoice at a CBE branch,
the CBE app or USSD using its bill reference there is no payment
deadline window.
</Text>
</Stack>
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<StatBox
label="Outstanding balance"
value={formatCurrency(outstanding, currency)}
/>
<StatBox label="Open invoices" value={String(openInvoices.length)} />
<StatBox
label="Paid invoices"
value={String(
invoices.filter(
(inv) => inv.status === Freight.InvoiceStatus.Paid,
).length,
)}
/>
</SimpleGrid>
{isLoading ? (
<Center py={64}>
<Loader color="edr-green" />
</Center>
) : isError ? (
<Alert color="red" title="Could not load invoices">
<Group gap="sm">
<Text size="sm">Something went wrong.</Text>
<Button size="compact-sm" variant="light" onClick={() => refetch()}>
Retry
</Button>
</Group>
</Alert>
) : invoices.length === 0 ? (
<Card withBorder radius="md" py={64}>
<Stack align="center" gap="xs">
<Receipt size={28} className="text-slate-300" />
<Text c="dimmed" size="sm">
No invoices yet. Finance bills your accumulated booking charges
in batches invoices will appear here once issued.
</Text>
</Stack>
</Card>
) : (
<Card withBorder radius="md" p={0} style={{ overflow: "hidden" }}>
<Box style={{ overflowX: "auto" }}>
<Table
highlightOnHover
verticalSpacing={12}
horizontalSpacing={20}
styles={{
th: {
fontSize: 11,
fontWeight: 700,
letterSpacing: "0.05em",
textTransform: "uppercase",
color: MUTED,
background: "#F8FAFC",
borderBottom: `1px solid ${BORDER}`,
whiteSpace: "nowrap",
},
td: { borderBottom: `1px solid ${BORDER}` },
}}
>
<Table.Thead>
<Table.Tr>
<Table.Th>Invoice</Table.Th>
<Table.Th>Issued</Table.Th>
<Table.Th>Due</Table.Th>
<Table.Th ta="right">Amount</Table.Th>
<Table.Th ta="right">Balance</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{invoices.map((inv) => (
<Table.Tr
key={inv.id}
style={{ cursor: "pointer" }}
onClick={() =>
navigate(`/shipping-line/invoices/${inv.id}`)
}
>
<Table.Td>
<Text fz={14} fw={700} ff="monospace" style={{ color: INK }}>
{inv.invoiceNumber}
</Text>
</Table.Td>
<Table.Td>
<Text fz={13}>{fmtDate(inv.issuedAt)}</Text>
</Table.Td>
<Table.Td>
<Text fz={13}>{fmtDate(inv.dueAt)}</Text>
</Table.Td>
<Table.Td ta="right">
<Text fz={13} fw={600}>
{formatCurrency(Number(inv.totalAmount), inv.currency)}
</Text>
</Table.Td>
<Table.Td ta="right">
<Text fz={13} fw={600}>
{formatCurrency(
Number(inv.balanceAmount ?? inv.totalAmount),
inv.currency,
)}
</Text>
</Table.Td>
<Table.Td>
<InvoiceStatusBadge status={inv.status} />
</Table.Td>
<Table.Td ta="right">
<Button
size="compact-sm"
variant={isPayable(inv.status) ? "filled" : "light"}
color="edr-green"
rightSection={<ChevronRight size={14} />}
onClick={(e) => {
e.stopPropagation();
navigate(`/shipping-line/invoices/${inv.id}`);
}}
>
{isPayable(inv.status) ? "View & pay" : "View"}
</Button>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
</Card>
)}
<Alert
icon={<Info size={16} />}
color="blue"
variant="light"
p="sm"
>
<Text size="sm">
Open an invoice and choose <b>Pay</b> <b>CBE</b> to get its bill
reference. Pay against that reference at any CBE branch, in the CBE
app or via USSD; the invoice settles automatically once CBE confirms.
</Text>
</Alert>
</Stack>
);
}

View File

@@ -1,6 +1,7 @@
export { default as ShippingLineHomePage } from "./ShippingLineHomePage";
export { default as ShippingLineBookingsPage } from "./ShippingLineBookingsPage";
export { default as ShippingLineBookingDetailPage } from "./ShippingLineBookingDetailPage";
export { default as ShippingLineCompletePage } from "./ShippingLineCompletePage";
export { default as ShippingLineInvoicesPage } from "./ShippingLineInvoicesPage";
export { default as ShippingLineSettingsPage } from "./ShippingLineSettingsPage";
export { default as ShippingLineHelpPage } from "./ShippingLineHelpPage";

View File

@@ -56,6 +56,7 @@ import {
UpdateDropdownSettingDto,
} from "@/types/dropdownSettings";
import type {
AccountInfoResponse,
ChangeRequestResponse,
CompanyDocument,
CompanyInfoResponse,
@@ -188,7 +189,7 @@ export const api = {
},
companies: {
getInfo: endpoint<void, CompanyInfoResponse | null>(
getInfo: endpoint<void, AccountInfoResponse | null>(
"companies",
"getInfo",
companiesService.getInfo,

View File

@@ -12,17 +12,94 @@ const BASE = "/api/shipping-line-bookings";
export const SHIPPING_LINE_BOOKING_DOCUMENTS_CODE =
"shipping_line_booking_documents";
/** One physical container persisted on a booking line. */
export interface ShippingLineBookingContainerUnit {
id: string;
containerNumber?: string | null;
sealNumber?: string | null;
vgmTons?: number;
isHazardous?: boolean;
isReefer?: boolean;
}
/** One persisted container line of a completed booking. */
export interface ShippingLineBookingContainer {
id: string;
containerSize?: string | null;
quantity: number;
vgmPerUnitTons?: number;
totalVgmTons?: number;
hazardousQuantity?: number;
reeferQuantity?: number;
wagonsRequired?: number;
containerType?: {
id: string;
label?: string | null;
code?: string | null;
sizeFt?: number | null;
} | null;
units?: ShippingLineBookingContainerUnit[];
}
/**
* A shipping-line booking as the portal sees it.
*
* `hasQueriedDocuments` is computed server-side: a reviewer querying a document
* leaves the booking on DOCUMENTS_UNDER_REVIEW, so the booking status alone
* cannot tell the UI that the shipping line has something to fix.
* cannot tell the UI that the shipping line has something to fix. The cargo
* fields are loaded by the detail endpoint for the detail page's cargo tab.
*/
export type ShippingLineBooking = Freight.IBooking & {
hasQueriedDocuments?: boolean;
/** Operations' note when the request was returned for changes (detail only). */
operationChangeNote?: string | null;
bookingContainers?: ShippingLineBookingContainer[];
cargoType?: { id: string; cargoTypeName?: string | null } | null;
cargoFreeText?: string | null;
isHazardous?: boolean;
cargoTotalWeightVgm?: number;
bulkTotalWeightTons?: number | null;
};
/** The authoritative quote returned by the price-preview endpoint. */
export interface ShippingLinePriceQuote {
totalAmount: number;
currency: string;
lineItems: Freight.PricingBreakdownLineItem[];
warnings: string[];
}
/** One wagon the batch engine allocated to the booking. */
export interface ShippingLineBookingWagon {
id: string;
status: string;
loadType?: string | null;
allocatedWeightTons: number;
sequenceNo: number | null;
wagonNumber: string | null;
wagonType: string | null;
capacityTons: number;
containerNumbers: string[];
}
/** Operations view: the train the booking rides + its allocated wagons. */
export interface ShippingLineBookingOperations {
train: {
id: string;
reference?: string | null;
trainNumber?: string | null;
status: string;
direction?: string | null;
scheduledDepartureDate: string;
scheduledArrivalDate?: string | null;
originLabel: string;
destinationLabel: string;
/** true once operations assigned it; false while it is only requested. */
assigned: boolean;
} | null;
wagons: ShippingLineBookingWagon[];
}
/** A bookable lane. Its direction is frozen server-side from the yards. */
export interface ShippingLineRouteOption {
id: string;
@@ -87,12 +164,25 @@ export interface InitiateShippingLineBookingPayload {
}
/** One container line of a CONTAINER completion. */
/** One physical container — number, seal, VGM and its handling switches. */
export interface CompleteBookingContainerUnit {
containerNumber: string;
sealNumber?: string;
vgmTons: number;
isHazardous?: boolean;
isReefer?: boolean;
}
export interface CompleteBookingContainerLine {
containerTypeId: string;
/** Either the type id or a size string ("20ft" | "40ft") — the server resolves size→type. */
containerTypeId?: string;
containerSize?: string;
quantity: number;
vgmPerUnitTons?: number;
hazardousQuantity?: number;
reeferQuantity?: number;
/** Per-container rows — when sent, the server derives counts/VGM from them. */
units?: CompleteBookingContainerUnit[];
}
/**
@@ -102,10 +192,17 @@ export interface CompleteBookingContainerLine {
*/
export interface CompleteShippingLineBookingPayload {
scheduledDate: string;
/**
* Which dedicated train the booking rides. Required when more than one of
* the line's trains departs on the chosen day; implicit with one departure.
*/
trainScheduleId?: string;
paymentCurrency?: string;
containers?: CompleteBookingContainerLine[];
cargoTypeId?: string;
cargoWeightTons?: number;
bulkHazardousQuantity?: number;
bulkReeferQuantity?: number;
cargoFreeText?: string;
}
@@ -153,6 +250,48 @@ export const shippingLineBookingsService = {
return data.data ?? data;
},
/**
* The line's dedicated trains for a shipment day, each with per-wagon-type
* free space — the completion form's train picker. Cargo context refines
* the availability figures.
*/
trainsForDay: async (
id: string,
date?: string,
cargo?: { containerSizes?: string[]; cargoTypeId?: string; wagons?: number },
): Promise<Freight.ExportTrainOption[]> => {
const { data } = await client.get(`${BASE}/${id}/trains`, {
params: {
...(date ? { date } : {}),
...(cargo?.containerSizes?.length
? { sizes: cargo.containerSizes.join(",") }
: {}),
...(cargo?.cargoTypeId ? { cargoTypeId: cargo.cargoTypeId } : {}),
...(cargo?.wagons ? { wagons: cargo.wagons } : {}),
},
});
return data.data ?? data;
},
/**
* Authoritative price quote for the completion payload — the same compute
* /complete runs. Server saves the breakdown + rate snapshots on the
* booking (refreshed on every re-preview); nothing else is persisted.
*/
pricePreview: async (
id: string,
payload: CompleteShippingLineBookingPayload,
): Promise<ShippingLinePriceQuote> => {
const { data } = await client.post(`${BASE}/${id}/price-preview`, payload);
return data.data ?? data;
},
/** The train the booking rides + the wagons allocated to it. */
operations: async (id: string): Promise<ShippingLineBookingOperations> => {
const { data } = await client.get(`${BASE}/${id}/operations`);
return data.data ?? data;
},
/**
* Complete an approved (CLEARANCE_READY) booking: cargo + shipment day.
* The server prices it off the line's negotiated rates, records the charge