mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
shipping line
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Maker–checker 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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
/**
|
||||
* Maker–checker 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;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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++,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// ── Maker–checker 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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -77,6 +77,14 @@ describe("ShippingLineCreditsService", () => {
|
||||
service = new ShippingLineCreditsService(
|
||||
dataSource as never,
|
||||
creditsRepo as never,
|
||||
// Approvals repo — only the invoice maker–checker 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,
|
||||
);
|
||||
|
||||
@@ -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 + maker–checker 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 ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 (maker–checker).
|
||||
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,
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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 maker–checker 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 maker–checker
|
||||
// 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 (
|
||||
|
||||
@@ -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
|
||||
* maker–checker 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>
|
||||
);
|
||||
}
|
||||
@@ -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 (maker–checker
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -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 & issue
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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 } }}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user