mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' into freight/nati-2
This commit is contained in:
@@ -110,6 +110,7 @@ import { AiModule } from "./modules/ai/ai.module";
|
||||
import { AuditModule } from "./modules/audit/audit.module";
|
||||
import { LoggerMiddleware } from "./logger.middleware";
|
||||
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
|
||||
import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache";
|
||||
|
||||
if (!process.env.APPLICATION_NAME) {
|
||||
process.env.APPLICATION_NAME = "freight";
|
||||
@@ -273,6 +274,9 @@ if (!process.env.APPLICATION_NAME) {
|
||||
ApprovedFirstLastMileDemoBookingsSeeder,
|
||||
PaidImportExportMileDemoSeeder,
|
||||
LoginAudienceMiddleware,
|
||||
// Feeds position-TYPE grants to the synchronous permission checks — without
|
||||
// it, staff whose permissions live on their position type resolve to none.
|
||||
PositionTypePermissionsCache,
|
||||
],
|
||||
})
|
||||
export class AppModule implements OnApplicationBootstrap {
|
||||
|
||||
@@ -57,6 +57,18 @@ export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
|
||||
export const BookingDocReviewAlert = () =>
|
||||
BookingStaff(FREIGHT_PERMS.bookings.docReviewAlert);
|
||||
|
||||
/** Staff wagon-cancellation history list (admin side). */
|
||||
export const WagonCancellationView = () =>
|
||||
BookingStaff(FREIGHT_PERMS.bookings.wagonCancellationView);
|
||||
|
||||
/** Staff void of a customer's pending (fee-unpaid) wagon cancellation. */
|
||||
export const WagonCancellationVoid = () =>
|
||||
BookingStaff(FREIGHT_PERMS.bookings.wagonCancellationVoid);
|
||||
|
||||
/** Staff rebook of a customer's wagon-cancellation credit on their behalf. */
|
||||
export const WagonCancellationRebook = () =>
|
||||
BookingStaff(FREIGHT_PERMS.bookings.wagonCancellationRebook);
|
||||
|
||||
export const TrainSchedulingView = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.view);
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import {
|
||||
assertCanApproveContractStep,
|
||||
canEditContractStep,
|
||||
collectPermissionKeys,
|
||||
hasFreightPermission,
|
||||
setPositionTypePermissionResolver,
|
||||
} from './freight-permission.util';
|
||||
import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
|
||||
|
||||
@@ -49,3 +52,72 @@ describe('canEditContractStep (strict per-step edit gate)', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The GL lockout regression: positions created through the admin UI keep their
|
||||
* grants on the position TYPE, and the JWT only ever snapshots DIRECT position
|
||||
* permissions. Without the type resolver those staff resolved to zero
|
||||
* permissions, so every gated route rejected them — which is what kept GL
|
||||
* officers out of their own clearance detail pages.
|
||||
*/
|
||||
describe('collectPermissionKeys — position-type grants', () => {
|
||||
const CLEARANCE = FREIGHT_PERMS.contracts.clearanceReview;
|
||||
|
||||
afterEach(() => {
|
||||
setPositionTypePermissionResolver(() => []);
|
||||
});
|
||||
|
||||
const glOfficer = {
|
||||
roles: [],
|
||||
permissions: [],
|
||||
employee: {
|
||||
position: {
|
||||
permissions: [], // admin-created position carries NO direct grants
|
||||
positionType: { key: 'commercial-global-logistics-(et)-officer' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it('resolves permissions carried by the position type', () => {
|
||||
setPositionTypePermissionResolver((key) =>
|
||||
key === 'commercial-global-logistics-(et)-officer' ? [CLEARANCE] : [],
|
||||
);
|
||||
|
||||
expect(collectPermissionKeys(glOfficer)).toContain(CLEARANCE);
|
||||
expect(hasFreightPermission(glOfficer, CLEARANCE)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles the array-shaped employee payload too', () => {
|
||||
setPositionTypePermissionResolver(() => [CLEARANCE]);
|
||||
|
||||
const arrayShaped = {
|
||||
roles: [],
|
||||
permissions: [],
|
||||
employee: [
|
||||
{
|
||||
positions: [
|
||||
{ permissions: [], positionType: { key: 'djibouti-gl-officer' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(hasFreightPermission(arrayShaped, CLEARANCE)).toBe(true);
|
||||
});
|
||||
|
||||
it('still rejects when neither the position nor its type grants it', () => {
|
||||
setPositionTypePermissionResolver(() => []);
|
||||
|
||||
expect(hasFreightPermission(glOfficer, CLEARANCE)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps direct position permissions working with no resolver installed', () => {
|
||||
const direct = {
|
||||
roles: [],
|
||||
permissions: [],
|
||||
employee: { position: { permissions: [{ key: CLEARANCE }] } },
|
||||
};
|
||||
|
||||
expect(hasFreightPermission(direct, CLEARANCE)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,12 +42,41 @@ export function isFreightApprovalAdmin(user: MeLikeUser | null | undefined): boo
|
||||
return isSuperAdmin(user) || isOrganizationAdmin(user);
|
||||
}
|
||||
|
||||
/** Flat permission keys from JWT / session user (roles + position permissions). */
|
||||
/**
|
||||
* Permissions carried by a position TYPE rather than the position itself.
|
||||
*
|
||||
* The JWT snapshots only DIRECT position permissions, so type-level grants —
|
||||
* which is where admin-created positions keep theirs — are absent from the
|
||||
* token entirely. This resolver is installed at startup
|
||||
* (see `PositionTypePermissionsCache`) so the synchronous permission checks
|
||||
* below can still see them. Left as a no-op resolver until then, which
|
||||
* degrades to the old position-only behaviour rather than throwing.
|
||||
*/
|
||||
let positionTypePermissionResolver: (positionTypeKey: string) => string[] = () =>
|
||||
[];
|
||||
|
||||
export function setPositionTypePermissionResolver(
|
||||
resolver: (positionTypeKey: string) => string[],
|
||||
): void {
|
||||
positionTypePermissionResolver = resolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flat permission keys from JWT / session user: roles, position permissions,
|
||||
* and the grants held by each position's TYPE.
|
||||
*/
|
||||
export function collectPermissionKeys(user: MeLikeUser | null | undefined): string[] {
|
||||
if (!user) return [];
|
||||
|
||||
const keys = new Set<string>();
|
||||
|
||||
const addTypePermissions = (positionType: PositionTypeLike | null | undefined) => {
|
||||
if (!positionType?.key) return;
|
||||
for (const key of positionTypePermissionResolver(positionType.key)) {
|
||||
keys.add(key);
|
||||
}
|
||||
};
|
||||
|
||||
for (const p of user.permissions ?? []) {
|
||||
if (p.key) keys.add(p.key);
|
||||
}
|
||||
@@ -63,6 +92,7 @@ export function collectPermissionKeys(user: MeLikeUser | null | undefined): stri
|
||||
for (const p of pos.permissions ?? []) {
|
||||
if (p.key) keys.add(p.key);
|
||||
}
|
||||
addTypePermissions(pos.positionType);
|
||||
}
|
||||
}
|
||||
return [...keys];
|
||||
@@ -71,6 +101,7 @@ export function collectPermissionKeys(user: MeLikeUser | null | undefined): stri
|
||||
for (const p of employee.position?.permissions ?? []) {
|
||||
if (p.key) keys.add(p.key);
|
||||
}
|
||||
addTypePermissions(employee.position?.positionType);
|
||||
for (const delegated of employee.delegatedPositions ?? []) {
|
||||
for (const p of delegated.permissions ?? []) {
|
||||
if (p.key) keys.add(p.key);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { setPositionTypePermissionResolver } from './freight-permission.util';
|
||||
|
||||
/**
|
||||
* Permissions granted to a position TYPE (`iam.position_type_permissions`).
|
||||
*
|
||||
* A position type is the platform's notion of a role, and positions created
|
||||
* through the admin UI carry their grants there rather than on the position
|
||||
* itself. The JWT only ever snapshots DIRECT position permissions, so those
|
||||
* grants are invisible to `collectPermissionKeys` — staff on such a position
|
||||
* resolve to zero permissions and every permission-gated route rejects them.
|
||||
*
|
||||
* The permission checks (`hasFreightPermission`, `FreightPermissionGuard`) are
|
||||
* synchronous and sit on the request path, so the mapping is held in memory and
|
||||
* refreshed periodically rather than queried per request. The dataset is tiny
|
||||
* (tens of types, a few hundred rows), so a full reload is cheaper than any
|
||||
* incremental scheme.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PositionTypePermissionsCache implements OnModuleInit {
|
||||
private readonly logger = new Logger(PositionTypePermissionsCache.name);
|
||||
|
||||
/** position_type key → permission keys. Empty until the first load lands. */
|
||||
private byPositionTypeKey = new Map<string, string[]>();
|
||||
|
||||
// ponytail: fixed 5-min refresh, no invalidation hook. A permission granted
|
||||
// in the admin UI takes up to one interval to reach the guards. Wire the
|
||||
// grant mutation to call `refresh()` if that lag ever matters.
|
||||
private static readonly REFRESH_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
await this.refresh();
|
||||
// Hand the lookup to the permission utils, whose checks are synchronous and
|
||||
// therefore cannot query IAM themselves.
|
||||
setPositionTypePermissionResolver((positionTypeKey) =>
|
||||
this.get(positionTypeKey),
|
||||
);
|
||||
const timer = setInterval(() => {
|
||||
void this.refresh();
|
||||
}, PositionTypePermissionsCache.REFRESH_INTERVAL_MS);
|
||||
// Never hold the process open for a cache refresh.
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
/** Permission keys for a position-type key ([] when unknown/not loaded). */
|
||||
get(positionTypeKey: string | undefined | null): string[] {
|
||||
if (!positionTypeKey) return [];
|
||||
return this.byPositionTypeKey.get(positionTypeKey) ?? [];
|
||||
}
|
||||
|
||||
/** Reload the whole mapping. Failures keep the previous snapshot in place. */
|
||||
async refresh(): Promise<void> {
|
||||
try {
|
||||
const rows: { position_type_key: string; permission_key: string }[] =
|
||||
await this.dataSource.query(
|
||||
`SELECT pt.key AS position_type_key, perm.key AS permission_key
|
||||
FROM iam.position_type_permissions ptp
|
||||
JOIN iam.position_types pt ON pt.id = ptp.position_type_id
|
||||
JOIN iam.permissions perm ON perm.id = ptp.permission_id`,
|
||||
);
|
||||
|
||||
const next = new Map<string, string[]>();
|
||||
for (const row of rows) {
|
||||
if (!row.position_type_key || !row.permission_key) continue;
|
||||
const keys = next.get(row.position_type_key);
|
||||
if (keys) keys.push(row.permission_key);
|
||||
else next.set(row.position_type_key, [row.permission_key]);
|
||||
}
|
||||
this.byPositionTypeKey = next;
|
||||
} catch (err) {
|
||||
// iam schema unreachable — keep serving the previous snapshot rather than
|
||||
// dropping every type-derived permission and locking staff out.
|
||||
this.logger.warn(
|
||||
`Position-type permission refresh failed: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Partial wagon cancellation with rebooking credit.
|
||||
*
|
||||
* One row per cancellation cycle on a PAID booking: the customer asks to drop
|
||||
* N wagons, pays a per-wagon cancellation fee (rates row
|
||||
* rate_type = 'CANCELLATION_FEE', rate_unit = 'PER_WAGON'), and the dropped cargo becomes a
|
||||
* rebookable credit. The credit is redeemed by creating a fresh booking
|
||||
* through the normal under-contract create path (which re-checks contract
|
||||
* validity and caps), immediately marked PAID — the freight was already paid
|
||||
* on the original booking, only the fee is new money.
|
||||
*
|
||||
* cancelled_quantities carries what was cut, in the booking's own terms:
|
||||
* `{ bulkTons }` for bulk, `{ bySize: { "20": 4, "40": 3 } }` for container.
|
||||
* Container numbers are NOT stored here — they are recovered at rebook time
|
||||
* from the unit rows the reduction soft-deleted (same hybrid pattern as
|
||||
* RemainderPlacementService).
|
||||
*/
|
||||
export class BookingWagonCancellations3300000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.booking_wagon_cancellations (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
booking_id uuid NOT NULL REFERENCES freight.bookings(id),
|
||||
rebooked_booking_id uuid REFERENCES freight.bookings(id),
|
||||
wagons_cancelled numeric(6,2) NOT NULL CHECK (wagons_cancelled > 0),
|
||||
weight_tons numeric(12,3) NOT NULL DEFAULT 0,
|
||||
cancelled_quantities jsonb NOT NULL,
|
||||
credit_amount numeric(14,2) NOT NULL DEFAULT 0,
|
||||
fee_rate_id uuid REFERENCES freight.rates(id),
|
||||
fee_amount numeric(14,2) NOT NULL CHECK (fee_amount >= 0),
|
||||
fee_currency varchar(8) NOT NULL DEFAULT 'ETB',
|
||||
fee_invoice_id uuid REFERENCES freight.invoices(id),
|
||||
fee_paid_at timestamptz,
|
||||
status varchar(30) NOT NULL DEFAULT 'FEE_PENDING',
|
||||
reason text,
|
||||
requested_by_user_id uuid,
|
||||
rebooked_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
)
|
||||
`);
|
||||
// One open (fee-unpaid) cancellation per booking — closes the double-click
|
||||
// race without app-level locking.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_open_wagon_cancellation_per_booking
|
||||
ON freight.booking_wagon_cancellations (booking_id)
|
||||
WHERE status = 'FEE_PENDING' AND deleted_at IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_bwc_booking
|
||||
ON freight.booking_wagon_cancellations (booking_id)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_bwc_status
|
||||
ON freight.booking_wagon_cancellations (status)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_wagon_cancellations`);
|
||||
}
|
||||
}
|
||||
@@ -35,10 +35,57 @@ export class FreightMeService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Permissions granted to the position's TYPE (`iam.position_type_permissions`).
|
||||
* A position type is the platform's notion of a role, and admin-created
|
||||
* positions carry their grants there rather than on the position itself — but
|
||||
* the JWT only ever snapshots direct position permissions. Without this, staff
|
||||
* on such a position resolve to zero permissions and every permission-gated
|
||||
* route rejects them (this is what locked GL officers out of their clearance
|
||||
* detail pages). Resolved live from IAM, same as the position type above.
|
||||
*/
|
||||
private async lookupPositionTypePermissions(
|
||||
positionId: string | undefined,
|
||||
): Promise<string[]> {
|
||||
if (!positionId) return [];
|
||||
try {
|
||||
const rows: { key: string }[] = await this.dataSource.query(
|
||||
`SELECT DISTINCT perm.key
|
||||
FROM iam.positions p
|
||||
JOIN iam.position_type_permissions ptp
|
||||
ON ptp.position_type_id = p.position_type_id
|
||||
JOIN iam.permissions perm ON perm.id = ptp.permission_id
|
||||
WHERE p.id = $1`,
|
||||
[positionId],
|
||||
);
|
||||
return rows.map((r) => r.key).filter(Boolean);
|
||||
} catch {
|
||||
return []; // iam schema unreachable — degrade to position-only permissions
|
||||
}
|
||||
}
|
||||
|
||||
async getEnrichedProfile(user: TCurrentUser) {
|
||||
const positionType = await this.lookupPositionType(
|
||||
user.employee?.position?.id,
|
||||
const positionId = user.employee?.position?.id;
|
||||
const [positionType, positionTypePermissionKeys] = await Promise.all([
|
||||
this.lookupPositionType(positionId),
|
||||
this.lookupPositionTypePermissions(positionId),
|
||||
]);
|
||||
|
||||
// Merge the type-level grants into the position's own permission list so
|
||||
// BOTH consumers see them: `collectPermissionKeys` below, and the
|
||||
// backoffice's `getPermissionKeys`, which walks this same nested array.
|
||||
const positionPermissions = [
|
||||
...(user.employee?.position?.permissions ?? []),
|
||||
];
|
||||
const seenPermissionKeys = new Set(
|
||||
positionPermissions.map((p) => p?.key).filter(Boolean),
|
||||
);
|
||||
for (const key of positionTypePermissionKeys) {
|
||||
if (!seenPermissionKeys.has(key)) {
|
||||
seenPermissionKeys.add(key);
|
||||
positionPermissions.push({ key } as (typeof positionPermissions)[number]);
|
||||
}
|
||||
}
|
||||
|
||||
const employee = user.employee
|
||||
? [
|
||||
@@ -56,7 +103,7 @@ export class FreightMeService {
|
||||
name: user.employee.position.name,
|
||||
isDelegate: user.employee.position.isDelegate,
|
||||
parentPositionId: user.employee.position.parentPositionId,
|
||||
permissions: user.employee.position.permissions ?? [],
|
||||
permissions: positionPermissions,
|
||||
positionType,
|
||||
},
|
||||
]
|
||||
@@ -65,7 +112,15 @@ export class FreightMeService {
|
||||
]
|
||||
: [];
|
||||
|
||||
const permissionKeys = collectPermissionKeys(user);
|
||||
// `collectPermissionKeys` reads the raw token (position-level only), so
|
||||
// union the type-level grants in — the backoffice prefers this flat list
|
||||
// over the nested array and would otherwise still see none of them.
|
||||
const permissionKeys = [
|
||||
...new Set([
|
||||
...collectPermissionKeys(user),
|
||||
...positionTypePermissionKeys,
|
||||
]),
|
||||
];
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
|
||||
@@ -20,6 +20,10 @@ import { FirstMileService } from "../first-mile/first-mile.service";
|
||||
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
||||
import { PriceLineItemDto } from "./dto/generate-price-response.dto";
|
||||
import { BookingsRepository } from "./bookings.repository";
|
||||
import {
|
||||
BookingWagonCancellationService,
|
||||
WAGON_CANCEL_FEE_INVOICE_TYPE,
|
||||
} from "./booking-wagon-cancellation.service";
|
||||
import { Booking } from "./entities/booking.entity";
|
||||
|
||||
/** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */
|
||||
@@ -58,6 +62,8 @@ export class BookingInvoiceService {
|
||||
private readonly firstMile: FirstMileService,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatch: BookingBatchService,
|
||||
@Inject(forwardRef(() => BookingWagonCancellationService))
|
||||
private readonly wagonCancellations: BookingWagonCancellationService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
@@ -90,6 +96,21 @@ export class BookingInvoiceService {
|
||||
return this.billing.generateInvoice(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the booking's open PREPAID invoice, if any — used when a
|
||||
* changes-requested resubmit restates the cargo, so the re-priced booking can
|
||||
* be re-invoiced. Throws when the invoice already has payments recorded
|
||||
* (cargo must not change out from under recorded money).
|
||||
*/
|
||||
async cancelUnpaidInvoiceForBooking(bookingId: string): Promise<void> {
|
||||
const existing = await this.billing.findPayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
bookingId,
|
||||
"PREPAID",
|
||||
);
|
||||
if (existing) await this.billing.cancelInvoice(existing.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* React to a booking invoice being paid — the settlement branch point. Per-type
|
||||
* reactions live here (not in the payment process): each invoice type advances
|
||||
@@ -108,6 +129,11 @@ export class BookingInvoiceService {
|
||||
await this.bookingBatch.reviveOfferForInvoice(payload.invoiceId);
|
||||
await this.advanceBookingOnPayment(payload.sourceId);
|
||||
break;
|
||||
case WAGON_CANCEL_FEE_INVOICE_TYPE:
|
||||
// Partial wagon cancellation: the fee settled — reduce the booking and
|
||||
// release the cancelled wagons (T2 of the cancellation cycle).
|
||||
await this.wagonCancellations.onFeePaid(payload.invoiceId);
|
||||
break;
|
||||
default:
|
||||
this.logger.warn(
|
||||
`Unhandled booking invoice type "${payload.type}" paid (${payload.invoiceId})`,
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
Optional,
|
||||
} from "@nestjs/common";
|
||||
import { EventEmitter2, OnEvent } from "@nestjs/event-emitter";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
|
||||
import {
|
||||
BookingBatchService,
|
||||
@@ -56,11 +59,18 @@ export class BookingTransitionService {
|
||||
private readonly bookingClearanceService: BookingClearanceService,
|
||||
@Inject(forwardRef(() => ClearanceWorkflowService))
|
||||
private readonly workflowService: ClearanceWorkflowService,
|
||||
private readonly invoiceService: BookingInvoiceService,
|
||||
// forwardRef: booking-invoice.service now pulls in the wagon-cancellation
|
||||
// service, whose cross-module imports close a require cycle through this
|
||||
// file — without it the class is undefined at decorator time.
|
||||
@Inject(forwardRef(() => BookingInvoiceService))
|
||||
private readonly invoiceService: BookingInvoiceService,
|
||||
private readonly containerValidationService: ContainerValidationService,
|
||||
private readonly notifier: BookingLifecycleNotifierService,
|
||||
private readonly events: EventEmitter2,
|
||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||
// Optional + last so the hand-constructed service in *.spec.ts files keeps
|
||||
// compiling; Nest injects it normally at runtime.
|
||||
@Optional() private readonly dataSource?: DataSource,
|
||||
) {}
|
||||
|
||||
private isPhasedCustoms(booking: Booking): boolean {
|
||||
@@ -429,6 +439,20 @@ export class BookingTransitionService {
|
||||
return fresh;
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer self-service cancel, allowed only before payment — no fee.
|
||||
* SELECTED_FOR_BATCH releases the wagon hold immediately; earlier statuses
|
||||
* take the plain cancel path (open invoices expired, nothing reserved yet).
|
||||
* Anything past payment falls through to cancel()'s status assertion.
|
||||
*/
|
||||
async customerCancel(bookingId: string, reason?: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
if (booking.status === "SELECTED_FOR_BATCH") {
|
||||
return this.cancelHold(bookingId, reason);
|
||||
}
|
||||
return this.cancel(bookingId, reason ?? "Customer cancelled before payment");
|
||||
}
|
||||
|
||||
async cancel(bookingId: string, reason: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, [
|
||||
@@ -978,6 +1002,15 @@ export class BookingTransitionService {
|
||||
// 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;
|
||||
// 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
|
||||
// legitimate "let the system choose".
|
||||
if (isExportTrain && !requestedId) {
|
||||
throw new BadRequestException(
|
||||
"Select a train for the chosen shipment day.",
|
||||
);
|
||||
}
|
||||
const scheduledBooking = {
|
||||
...booking,
|
||||
scheduledDate: date,
|
||||
@@ -1244,6 +1277,12 @@ export class BookingTransitionService {
|
||||
/** Flat list of physical container numbers on this booking (for the
|
||||
* customer truck-assignment container picker). */
|
||||
containerNumbers: string[];
|
||||
/** The allocated train, when the booking is placed on a schedule. */
|
||||
trainSchedule?: {
|
||||
trainNumber: string | null;
|
||||
reference: string | null;
|
||||
scheduledDepartureDate: Date | null;
|
||||
} | null;
|
||||
}
|
||||
> {
|
||||
// This enrichment runs AFTER the transition has committed. A failure here
|
||||
@@ -1296,6 +1335,32 @@ export class BookingTransitionService {
|
||||
`enrichBookingResponse: batch-offer lookup failed for ${booking.id}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
// Allocated train: number + schedule reference for the detail headers
|
||||
// (portal and backoffice). Degrades to null like every fragile field here.
|
||||
let trainSchedule: {
|
||||
trainNumber: string | null;
|
||||
reference: string | null;
|
||||
scheduledDepartureDate: Date | null;
|
||||
} | null = null;
|
||||
if (booking.trainScheduleId && this.dataSource) {
|
||||
try {
|
||||
const s = await this.dataSource.getRepository(TrainSchedule).findOne({
|
||||
where: { id: booking.trainScheduleId },
|
||||
});
|
||||
if (s) {
|
||||
trainSchedule = {
|
||||
trainNumber: s.trainNumber ?? null,
|
||||
reference: s.reference ?? null,
|
||||
scheduledDepartureDate: s.scheduledDepartureDate ?? null,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`enrichBookingResponse: train-schedule lookup failed for ${booking.id}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Physical container numbers entered at booking time (booking_container
|
||||
// units), flattened for the customer truck-assignment container picker.
|
||||
const containerNumbers = (booking.bookingContainers ?? [])
|
||||
@@ -1310,6 +1375,7 @@ export class BookingTransitionService {
|
||||
nextStep,
|
||||
activeBatchOffer,
|
||||
containerNumbers,
|
||||
trainSchedule,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { BookingWagonCancellation } from './entities/booking-wagon-cancellation.entity';
|
||||
|
||||
export interface WagonCancellationListFilter {
|
||||
status?: string[];
|
||||
/** Booking reference / company name search (staff list). */
|
||||
search?: string;
|
||||
companyId?: string;
|
||||
bookingId?: string;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BookingWagonCancellationsRepository extends BaseRepository<BookingWagonCancellation> {
|
||||
constructor(
|
||||
@InjectRepository(BookingWagonCancellation)
|
||||
repository: Repository<BookingWagonCancellation>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/** The one open (fee-unpaid) cancellation of a booking, if any. */
|
||||
findOpenForBooking(bookingId: string): Promise<BookingWagonCancellation | null> {
|
||||
return this.repository.findOne({
|
||||
where: { bookingId, status: 'FEE_PENDING' },
|
||||
});
|
||||
}
|
||||
|
||||
findByFeeInvoiceId(feeInvoiceId: string): Promise<BookingWagonCancellation | null> {
|
||||
return this.repository.findOne({ where: { feeInvoiceId } });
|
||||
}
|
||||
|
||||
/** Paged history — staff see everything, customers are scoped by companyId. */
|
||||
async list(
|
||||
filter: WagonCancellationListFilter,
|
||||
): Promise<{ items: BookingWagonCancellation[]; total: number }> {
|
||||
const page = Math.max(1, filter.page ?? 1);
|
||||
const pageSize = Math.min(100, Math.max(1, filter.pageSize ?? 10));
|
||||
|
||||
const qb = this.baseQuery();
|
||||
if (filter.bookingId) {
|
||||
qb.andWhere('(bwc.booking_id = :bookingId OR bwc.rebooked_booking_id = :bookingId)', {
|
||||
bookingId: filter.bookingId,
|
||||
});
|
||||
}
|
||||
if (filter.companyId) {
|
||||
qb.andWhere('booking.company_id = :companyId', { companyId: filter.companyId });
|
||||
}
|
||||
if (filter.status?.length) {
|
||||
qb.andWhere('bwc.status IN (:...statuses)', { statuses: filter.status });
|
||||
}
|
||||
if (filter.search) {
|
||||
qb.andWhere('(booking.reference ILIKE :search OR company.name ILIKE :search)', {
|
||||
search: `%${filter.search}%`,
|
||||
});
|
||||
}
|
||||
if (filter.from) qb.andWhere('bwc.created_at >= :from', { from: filter.from });
|
||||
if (filter.to) qb.andWhere('bwc.created_at <= :to', { to: filter.to });
|
||||
|
||||
// Property path (not raw column): skip/take builds a distinct-id subquery
|
||||
// and the ORDER BY must resolve inside it.
|
||||
const [items, total] = await qb
|
||||
.orderBy('bwc.createdAt', 'DESC')
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize)
|
||||
.getManyAndCount();
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
private baseQuery(): SelectQueryBuilder<BookingWagonCancellation> {
|
||||
return this.repository
|
||||
.createQueryBuilder('bwc')
|
||||
.leftJoinAndSelect('bwc.booking', 'booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('bwc.rebookedBooking', 'rebookedBooking')
|
||||
.leftJoinAndSelect('bwc.feeInvoice', 'feeInvoice');
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,12 @@ import { GenerateGrnDto } from './dto/generate-grn.dto';
|
||||
import { ContainerReceiptService } from './container-receipt.service';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
import { UpdateBookingDto } from './dto/update-booking.dto';
|
||||
import { BookingWagonCancellationService } from './booking-wagon-cancellation.service';
|
||||
import {
|
||||
FilterWagonCancellationsDto,
|
||||
RebookCancelledWagonsDto,
|
||||
RequestWagonCancellationDto,
|
||||
} from './dto/wagon-cancellation.dto';
|
||||
import {
|
||||
type AuthUserPayload,
|
||||
resolveAuthUserId,
|
||||
@@ -151,6 +157,7 @@ export class BookingsController {
|
||||
private readonly firstMileService: FirstMileService,
|
||||
private readonly lastMileService: LastMileService,
|
||||
private readonly userTradeAccessService: UserTradeAccessService,
|
||||
private readonly wagonCancellationService: BookingWagonCancellationService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@@ -507,6 +514,150 @@ export class BookingsController {
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
@Get(':id/wagons')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Allocated wagons for a booking (JSON) — empty until the paid booking is placed on a train',
|
||||
})
|
||||
async wagonAllocations(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
return this.bookingsService.wagonAllocations(id);
|
||||
}
|
||||
|
||||
// ── Partial wagon cancellation (paid bookings) ────────────────────────────
|
||||
// Customer endpoints are ownership-scoped (no portal permission keys); the
|
||||
// staff history/void/rebook variants are permission-gated below.
|
||||
|
||||
@Post(':id/wagon-cancellations/preview')
|
||||
@ApiOperation({ summary: 'Preview the fee/credit of a partial wagon cancellation (no writes)' })
|
||||
async previewWagonCancellation(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RequestWagonCancellationDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
return this.wagonCancellationService.previewCancellation(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/wagon-cancellations')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles',
|
||||
})
|
||||
async requestWagonCancellation(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RequestWagonCancellationDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
return this.wagonCancellationService.requestCancellation(id, dto, user?.id);
|
||||
}
|
||||
|
||||
@Get(':id/wagon-cancellations')
|
||||
@ApiOperation({ summary: 'Wagon-cancellation history of one booking (owner or staff)' })
|
||||
async listBookingWagonCancellations(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
const staff =
|
||||
hasFreightPermission(user, FREIGHT_PERMS.bookings.view) ||
|
||||
hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView);
|
||||
if (!staff) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
return this.wagonCancellationService.list({ bookingId: id, pageSize: 100 });
|
||||
}
|
||||
|
||||
@Get('wagon-cancellations/my')
|
||||
@ApiOperation({ summary: 'Wagon-cancellation history of the calling customer (paginated, filterable)' })
|
||||
async listMyWagonCancellations(
|
||||
@Query() filter: FilterWagonCancellationsDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const companyId = await this.bookingsService.resolveCustomerCompanyId(user?.id ?? '');
|
||||
if (!companyId) throw new ForbiddenException('No customer company for this user.');
|
||||
return this.wagonCancellationService.list({
|
||||
companyId,
|
||||
status: filter.statuses,
|
||||
search: filter.search,
|
||||
from: filter.from ? new Date(filter.from) : undefined,
|
||||
to: filter.to ? new Date(filter.to) : undefined,
|
||||
page: filter.page,
|
||||
pageSize: filter.pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('wagon-cancellations/history')
|
||||
@WagonCancellationView()
|
||||
@ApiOperation({ summary: 'All wagon cancellations (staff, paginated, filterable)' })
|
||||
async listAllWagonCancellations(@Query() filter: FilterWagonCancellationsDto) {
|
||||
return this.wagonCancellationService.list({
|
||||
status: filter.statuses,
|
||||
search: filter.search,
|
||||
from: filter.from ? new Date(filter.from) : undefined,
|
||||
to: filter.to ? new Date(filter.to) : undefined,
|
||||
page: filter.page,
|
||||
pageSize: filter.pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('wagon-cancellations/:cancellationId/withdraw')
|
||||
@ApiOperation({ summary: 'Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)' })
|
||||
async withdrawWagonCancellation(
|
||||
@Param('cancellationId', ParseUUIDPipe) cancellationId: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertWagonCancellationActor(
|
||||
cancellationId,
|
||||
user,
|
||||
FREIGHT_PERMS.bookings.wagonCancellationVoid,
|
||||
);
|
||||
return this.wagonCancellationService.withdraw(cancellationId);
|
||||
}
|
||||
|
||||
@Post('wagon-cancellations/:cancellationId/rebook')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)',
|
||||
})
|
||||
async rebookWagonCancellation(
|
||||
@Param('cancellationId', ParseUUIDPipe) cancellationId: string,
|
||||
@Body() dto: RebookCancelledWagonsDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertWagonCancellationActor(
|
||||
cancellationId,
|
||||
user,
|
||||
FREIGHT_PERMS.bookings.wagonCancellationRebook,
|
||||
);
|
||||
return this.wagonCancellationService.rebook(cancellationId, dto, user?.id);
|
||||
}
|
||||
|
||||
/** Owner-or-staff gate shared by the per-cancellation actions. */
|
||||
private async assertWagonCancellationActor(
|
||||
cancellationId: string,
|
||||
user: TCurrentUser,
|
||||
staffPermission: string,
|
||||
): Promise<void> {
|
||||
if (hasFreightPermission(user, staffPermission)) return;
|
||||
const row = await this.wagonCancellationService.findById(cancellationId);
|
||||
const booking = await this.bookingsService.findById(row.bookingId);
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
|
||||
@Get(':id/customer-trucks')
|
||||
@MixedAudience([
|
||||
FREIGHT_PERMS.bookings.view,
|
||||
@@ -1382,6 +1533,19 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(":id/customer-cancel")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Customer cancels their own booking before payment — no cancellation fee",
|
||||
})
|
||||
async customerCancel(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: RejectBookingDto,
|
||||
) {
|
||||
const booking = await this.transitionService.customerCancel(id, dto.reason);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(":id/cancel-hold")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
|
||||
@@ -39,6 +39,9 @@ import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
||||
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
|
||||
import { BookingReviewNote } from './entities/booking-review-note.entity';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { BookingWagonCancellation } from './entities/booking-wagon-cancellation.entity';
|
||||
import { BookingWagonCancellationsRepository } from './booking-wagon-cancellations.repository';
|
||||
import { BookingWagonCancellationService } from './booking-wagon-cancellation.service';
|
||||
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
|
||||
import { CustomerTruckContainer } from './entities/customer-truck-container.entity';
|
||||
import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository';
|
||||
@@ -66,6 +69,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
BookingReviewNote,
|
||||
BookingContractSignature,
|
||||
BookingContainerAllocation,
|
||||
BookingWagonCancellation,
|
||||
CustomerTruckAssignment,
|
||||
CustomerTruckContainer,
|
||||
]),
|
||||
@@ -110,6 +114,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
CustomerTruckAssignmentsRepository,
|
||||
CustomerTruckService,
|
||||
ContainerReceiptService,
|
||||
BookingWagonCancellationsRepository,
|
||||
BookingWagonCancellationService,
|
||||
],
|
||||
exports: [
|
||||
BookingsService,
|
||||
@@ -121,6 +127,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
ConsolidationService,
|
||||
CustomerTruckService,
|
||||
ContainerReceiptService,
|
||||
BookingWagonCancellationService,
|
||||
],
|
||||
})
|
||||
export class BookingsModule { }
|
||||
|
||||
@@ -339,6 +339,64 @@ export class BookingsService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocated wagons of a booking as JSON — the portal's "Wagons" tab. Same
|
||||
* join chain as the carriage acceptance sheet, but structured (containers as
|
||||
* an array per wagon, bulk load description when the wagon carries bulk).
|
||||
* Empty array until the booking has been allocated onto a train.
|
||||
*/
|
||||
async wagonAllocations(bookingId: string): Promise<unknown[]> {
|
||||
return this.dataSource.query(
|
||||
`SELECT a.id AS "allocationId",
|
||||
tsw.sequence_no AS "sequenceNo",
|
||||
w.wagon_number AS "wagonNumber",
|
||||
COALESCE(wt.name, wt.code) AS "wagonType",
|
||||
wt.code AS "wagonTypeCode",
|
||||
wt.tare_weight_tons AS "tareWeightTons",
|
||||
tsw.capacity_tons AS "capacityTons",
|
||||
tsw.length_meters AS "lengthMeters",
|
||||
a.allocated_weight_tons AS "allocatedWeightTons",
|
||||
a.load_type AS "loadType",
|
||||
a.status AS "status",
|
||||
s.train_number AS "trainNumber",
|
||||
s.scheduled_departure_date AS "departureAt",
|
||||
so.label AS "originStation",
|
||||
sd.label AS "destinationStation",
|
||||
bl.cargo_description AS "bulkCargoDescription",
|
||||
bl.quantity AS "bulkQuantity",
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'containerNumber', ci.container_number,
|
||||
'sealNumber', ci.seal_number,
|
||||
'positionOnWagon', ci.position_on_wagon,
|
||||
'grossWeightTons', ci.gross_weight_tons
|
||||
) ORDER BY ci.position_on_wagon, ci.container_number
|
||||
) FILTER (WHERE ci.id IS NOT NULL),
|
||||
'[]'
|
||||
) AS "containers"
|
||||
FROM freight.wagon_booking_allocations a
|
||||
JOIN freight.train_set_wagons tsw
|
||||
ON tsw.id = a.train_set_wagon_id AND tsw.deleted_at IS NULL
|
||||
LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
|
||||
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
|
||||
LEFT JOIN freight.train_schedules s
|
||||
ON s.train_set_id = tsw.train_set_id AND s.deleted_at IS NULL
|
||||
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
|
||||
LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id
|
||||
LEFT JOIN freight.wagon_allocation_container_items ci
|
||||
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
|
||||
LEFT JOIN freight.wagon_allocation_bulk_loads bl
|
||||
ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL
|
||||
WHERE a.booking_id = $1 AND a.deleted_at IS NULL
|
||||
GROUP BY tsw.id, a.id, w.wagon_number, wt.name, wt.code, wt.tare_weight_tons,
|
||||
s.train_number, s.scheduled_departure_date, so.label, sd.label,
|
||||
bl.cargo_description, bl.quantity
|
||||
ORDER BY tsw.sequence_no`,
|
||||
[bookingId],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the booking amount across its wagons, proportional to allocated weight
|
||||
* (equal shares when no weights are recorded). The last row absorbs the rounding
|
||||
@@ -1425,7 +1483,46 @@ export class BookingsService {
|
||||
tradeDirection,
|
||||
);
|
||||
}
|
||||
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
|
||||
// Re-pinning the departure day on an edit (e.g. fixing a CHANGES_REQUESTED
|
||||
// booking) must obey the same gate as creation: the route needs an OPEN
|
||||
// departure on that EAT day that can carry the cargo. Skipped when the day
|
||||
// didn't change, for general contracts (period-based, no pinned day) and
|
||||
// for intercity (staff assign a passing train later).
|
||||
if (dto.scheduledDate) {
|
||||
const day = eatDay(new Date(dto.scheduledDate));
|
||||
const dayChanged =
|
||||
!existing.scheduledDate || eatDay(existing.scheduledDate) !== day;
|
||||
if (
|
||||
dayChanged &&
|
||||
existing.bookingType !== 'GENERAL_CONTRACT' &&
|
||||
tradeDirection !== 'DOMESTIC'
|
||||
) {
|
||||
const { hasDeparture, hasCompatible } =
|
||||
await this.trainSchedulingService.checkDayCargoCompatibility(
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
day,
|
||||
{
|
||||
freightType: freightType as 'CONTAINER' | 'BULK',
|
||||
cargoTypeId,
|
||||
containerTypeIds: containers
|
||||
.map((c) => c.containerTypeId)
|
||||
.filter((cid): cid is string => Boolean(cid)),
|
||||
},
|
||||
);
|
||||
if (!hasDeparture) {
|
||||
throw new BadRequestException(
|
||||
'No departures available on the selected day for this route',
|
||||
);
|
||||
}
|
||||
if (!hasCompatible) {
|
||||
throw new BadRequestException(
|
||||
'No wagon on the selected day can carry this cargo type — please choose another day',
|
||||
);
|
||||
}
|
||||
}
|
||||
updates.scheduledDate = new Date(dto.scheduledDate);
|
||||
}
|
||||
if (dto.estimatedShipmentDate)
|
||||
updates.estimatedShipmentDate = new Date(dto.estimatedShipmentDate);
|
||||
if (dto.startDate) updates.startDate = new Date(dto.startDate);
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
import { WAGON_CANCELLATION_STATUSES } from '../entities/booking-wagon-cancellation.entity';
|
||||
|
||||
export class CancelContainerLineDto {
|
||||
@ApiProperty({ description: 'Container size (ft) as stored on the booking line, e.g. "20", "40"' })
|
||||
@IsString()
|
||||
containerSize!: string;
|
||||
|
||||
@ApiProperty({ description: 'How many units of this size to cancel' })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
quantity!: number;
|
||||
}
|
||||
|
||||
export class RequestWagonCancellationDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Cancel SPECIFIC allocated wagons: wagon_booking_allocation ids from GET /bookings/:id/wagons. ' +
|
||||
'When set, wagons/containers are derived from the selected wagons and the other fields are ignored.',
|
||||
type: [String],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
wagonAllocationIds?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'BULK bookings: number of wagons to cancel (tons derived proportionally)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0.5)
|
||||
wagons?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'CONTAINER bookings: units to cancel per size (wagons derived per size)',
|
||||
type: [CancelContainerLineDto],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CancelContainerLineDto)
|
||||
containers?: CancelContainerLineDto[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Customer reason for the cancellation' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export class RebookCancelledWagonsDto {
|
||||
@ApiProperty({ description: 'Shipment day the credit is rebooked onto (ISO date)' })
|
||||
@IsDateString()
|
||||
scheduledDate!: string;
|
||||
}
|
||||
|
||||
export class FilterWagonCancellationsDto {
|
||||
@ApiPropertyOptional({ enum: WAGON_CANCELLATION_STATUSES, isArray: true })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsIn(WAGON_CANCELLATION_STATUSES as readonly string[], { each: true })
|
||||
statuses?: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Booking reference / company name search' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
from?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
to?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: 10 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
pageSize?: number;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { Invoice } from '../../billing/entities/invoice.entity';
|
||||
import { Rate } from '../../rule-engine/entities/rate.entity';
|
||||
import { Booking } from './booking.entity';
|
||||
|
||||
export const WAGON_CANCELLATION_STATUSES = [
|
||||
// Requested; fee invoice open; wagons still allocated to the customer.
|
||||
'FEE_PENDING',
|
||||
// Fee settled; booking reduced, wagons freed; credit waiting for a rebook.
|
||||
'CREDIT_AVAILABLE',
|
||||
// Credit redeemed into a new PAID booking (rebookedBookingId).
|
||||
'REBOOKED',
|
||||
// Customer/staff voided the request before paying the fee. Nothing changed.
|
||||
'WITHDRAWN',
|
||||
// Reserved for a future expiry policy; not set by code today.
|
||||
'EXPIRED',
|
||||
] as const;
|
||||
|
||||
export type WagonCancellationStatus = (typeof WAGON_CANCELLATION_STATUSES)[number];
|
||||
|
||||
/** Snapshot of one physical container unit cut by the cancellation. */
|
||||
export interface CancelledUnitSnapshot {
|
||||
containerSize: string;
|
||||
containerNumber: string;
|
||||
sealNumber?: string | null;
|
||||
vgmTons: number;
|
||||
isHazardous: boolean;
|
||||
isReefer: boolean;
|
||||
}
|
||||
|
||||
/** What the cancellation cut, in the booking's own quantity terms. */
|
||||
export interface CancelledQuantities {
|
||||
/** Bulk bookings: tons cut (PER_ITEM cargo: item count, matching cargoTotalWeightVgm). */
|
||||
bulkTons?: number;
|
||||
/** Container bookings: units cut per container size. */
|
||||
bySize?: Record<string, number>;
|
||||
/**
|
||||
* Container bookings: the exact physical units cut. Snapshotted at request
|
||||
* time when the customer picked specific wagons, otherwise at fee settlement
|
||||
* (LIFO trim). The rebook reconstructs the new booking from THESE — never
|
||||
* from a soft-deleted-row scan, which could pick up units dropped by an
|
||||
* unrelated batch split on the same booking.
|
||||
*/
|
||||
units?: CancelledUnitSnapshot[];
|
||||
/**
|
||||
* Specific-wagon cancellation: the wagon_booking_allocation ids the customer
|
||||
* picked in the Wagons tab. T2 releases exactly these (fallback to
|
||||
* newest-first for any id that no longer exists, e.g. after a re-batch).
|
||||
*/
|
||||
allocationIds?: string[];
|
||||
/**
|
||||
* The wagon allocations were already released from the schedule at REQUEST
|
||||
* time (policy: wagons free up immediately; the fee is still owed before the
|
||||
* credit can be rebooked). Tells T2 to skip its release step so it never
|
||||
* deletes wagons the batch engine re-assigned in between.
|
||||
*/
|
||||
releasedAtRequest?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One partial-wagon-cancellation cycle on a PAID booking — the audit trail and
|
||||
* the state machine. The credit itself is not a wallet balance: redeeming it
|
||||
* creates a real booking through the under-contract create path and marks it
|
||||
* PAID (see BookingWagonCancellationService).
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'booking_wagon_cancellations' })
|
||||
@Index(['bookingId'])
|
||||
@Index(['status'])
|
||||
export class BookingWagonCancellation extends BaseEntity {
|
||||
@Column({ name: 'booking_id', type: 'uuid' })
|
||||
bookingId!: string;
|
||||
|
||||
@ManyToOne(() => Booking)
|
||||
@JoinColumn({ name: 'booking_id' })
|
||||
booking?: Booking;
|
||||
|
||||
@Column({ name: 'rebooked_booking_id', type: 'uuid', nullable: true })
|
||||
rebookedBookingId?: string | null;
|
||||
|
||||
@ManyToOne(() => Booking, { nullable: true })
|
||||
@JoinColumn({ name: 'rebooked_booking_id' })
|
||||
rebookedBooking?: Booking | null;
|
||||
|
||||
@Column({ name: 'wagons_cancelled', type: 'numeric', precision: 6, scale: 2 })
|
||||
wagonsCancelled!: number;
|
||||
|
||||
@Column({ name: 'weight_tons', type: 'numeric', precision: 12, scale: 3, default: 0 })
|
||||
weightTons!: number;
|
||||
|
||||
@Column({ name: 'cancelled_quantities', type: 'jsonb' })
|
||||
cancelledQuantities!: CancelledQuantities;
|
||||
|
||||
/**
|
||||
* The freight value of the cancelled part at the ORIGINAL booking's price —
|
||||
* informational (shown to the customer as "credit worth"); no refund is ever
|
||||
* issued from it, the credit is redeemed by rebooking.
|
||||
*/
|
||||
@Column({ name: 'credit_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||
creditAmount!: number;
|
||||
|
||||
@Column({ name: 'fee_rate_id', type: 'uuid', nullable: true })
|
||||
feeRateId?: string | null;
|
||||
|
||||
@ManyToOne(() => Rate, { nullable: true })
|
||||
@JoinColumn({ name: 'fee_rate_id' })
|
||||
feeRate?: Rate | null;
|
||||
|
||||
@Column({ name: 'fee_amount', type: 'numeric', precision: 14, scale: 2 })
|
||||
feeAmount!: number;
|
||||
|
||||
@Column({ name: 'fee_currency', type: 'varchar', length: 8, default: 'ETB' })
|
||||
feeCurrency!: string;
|
||||
|
||||
@Column({ name: 'fee_invoice_id', type: 'uuid', nullable: true })
|
||||
feeInvoiceId?: string | null;
|
||||
|
||||
@ManyToOne(() => Invoice, { nullable: true })
|
||||
@JoinColumn({ name: 'fee_invoice_id' })
|
||||
feeInvoice?: Invoice | null;
|
||||
|
||||
@Column({ name: 'fee_paid_at', type: 'timestamptz', nullable: true })
|
||||
feePaidAt?: Date | null;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 30, default: 'FEE_PENDING' })
|
||||
status!: string;
|
||||
|
||||
@Column({ name: 'reason', type: 'text', nullable: true })
|
||||
reason?: string | null;
|
||||
|
||||
@Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true })
|
||||
requestedByUserId?: string | null;
|
||||
|
||||
@Column({ name: 'rebooked_at', type: 'timestamptz', nullable: true })
|
||||
rebookedAt?: Date | null;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { ContractBookingService } from './contract-booking.service';
|
||||
|
||||
/**
|
||||
* OPERATION_CHANGES_REQUESTED resubmit with restated cargo. Operations can ask
|
||||
* for the cargo itself to change, so a completion payload that restates
|
||||
* containers must cancel the unpaid invoice, wipe the persisted cargo and
|
||||
* re-run the fresh-completion path (re-persist, re-price, re-invoice). A
|
||||
* payload without cargo keeps the day-only resubmit behavior.
|
||||
*/
|
||||
describe('ContractBookingService — changes-requested resubmit restating cargo', () => {
|
||||
const CONTRACT = {
|
||||
id: 'c-1',
|
||||
reference: 'CTR-1',
|
||||
contractKind: 'GENERAL',
|
||||
freightType: 'CONTAINER',
|
||||
tradeDirection: 'IMPORT',
|
||||
customsClearingEnabled: false,
|
||||
contractValidUntil: null,
|
||||
cargoScope: [],
|
||||
};
|
||||
|
||||
const bookingWithCargo = () => ({
|
||||
id: 'b-1',
|
||||
contractId: 'c-1',
|
||||
reference: 'BKG-1',
|
||||
status: 'OPERATION_CHANGES_REQUESTED',
|
||||
bookingContainers: [{ containerSize: '20FT', quantity: 4 }],
|
||||
cargoTotalWeightVgm: 80,
|
||||
originYardId: 'y-o',
|
||||
destinationYardId: 'y-d',
|
||||
});
|
||||
|
||||
function makeService() {
|
||||
const bookingsRepository = {
|
||||
findByIdWithFiles: jest.fn().mockResolvedValue(bookingWithCargo()),
|
||||
deleteContainers: jest.fn().mockResolvedValue(undefined),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const invoiceService = {
|
||||
cancelUnpaidInvoiceForBooking: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const trainSchedulingService = {
|
||||
assertBookingWindowOpen: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const contractsRepository = {
|
||||
findByIdWithRelations: jest.fn().mockResolvedValue(CONTRACT),
|
||||
};
|
||||
const service = new ContractBookingService(
|
||||
contractsRepository as never,
|
||||
bookingsRepository as never,
|
||||
{} as never, // bookingPricingService
|
||||
{} as never, // consolidationService
|
||||
{} as never, // containerTypesService
|
||||
{} as never, // ruleEngineService
|
||||
{} as never, // milestoneService
|
||||
invoiceService as never,
|
||||
{} as never, // bookingNotifier
|
||||
{} as never, // dataSource
|
||||
trainSchedulingService as never,
|
||||
{} as never, // bookingBatchService
|
||||
{} as never, // bookingTransitionService
|
||||
);
|
||||
return { service, bookingsRepository, invoiceService };
|
||||
}
|
||||
|
||||
// Both paths dead-end into a downstream private assert we replace with a
|
||||
// sentinel — which path threw tells us which branch the resubmit took.
|
||||
const SENTINEL = new Error('reached-branch');
|
||||
|
||||
it('restated cargo cancels the invoice, wipes cargo and re-runs fresh completion', async () => {
|
||||
const { service, bookingsRepository, invoiceService } = makeService();
|
||||
// First gate inside the fresh-completion (!hasCargo) path.
|
||||
jest
|
||||
.spyOn(
|
||||
service as never as { assertWithinQuantityCap: () => Promise<void> },
|
||||
'assertWithinQuantityCap',
|
||||
)
|
||||
.mockRejectedValue(SENTINEL);
|
||||
|
||||
await expect(
|
||||
service.completeUnderContract('c-1', 'b-1', {
|
||||
scheduledDate: new Date().toISOString(),
|
||||
containers: [{ containerSize: '20FT', quantity: 2 }],
|
||||
} as never),
|
||||
).rejects.toBe(SENTINEL);
|
||||
|
||||
expect(invoiceService.cancelUnpaidInvoiceForBooking).toHaveBeenCalledWith('b-1');
|
||||
expect(bookingsRepository.deleteContainers).toHaveBeenCalledWith('b-1');
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
|
||||
cargoTotalWeightVgm: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('a day-only resubmit keeps the persisted cargo and invoice untouched', async () => {
|
||||
const { service, bookingsRepository, invoiceService } = makeService();
|
||||
// First call inside the day-only (hasCargo) resubmit path.
|
||||
jest
|
||||
.spyOn(
|
||||
service as never as {
|
||||
assertPersistedContainersAvailable: () => Promise<void>;
|
||||
},
|
||||
'assertPersistedContainersAvailable',
|
||||
)
|
||||
.mockRejectedValue(SENTINEL);
|
||||
|
||||
await expect(
|
||||
service.completeUnderContract('c-1', 'b-1', {
|
||||
scheduledDate: new Date().toISOString(),
|
||||
} as never),
|
||||
).rejects.toBe(SENTINEL);
|
||||
|
||||
expect(invoiceService.cancelUnpaidInvoiceForBooking).not.toHaveBeenCalled();
|
||||
expect(bookingsRepository.deleteContainers).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -98,6 +98,9 @@ export class ContractBookingService {
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
// forwardRef: part of the booking-invoice ⇄ wagon-cancellation ⇄ contracts
|
||||
// require cycle (see BookingTransitionService).
|
||||
@Inject(forwardRef(() => BookingInvoiceService))
|
||||
private readonly invoiceService: BookingInvoiceService,
|
||||
private readonly bookingNotifier: BookingLifecycleNotifierService,
|
||||
private readonly dataSource: DataSource,
|
||||
@@ -692,11 +695,30 @@ export class ContractBookingService {
|
||||
});
|
||||
|
||||
const freightType = contract.freightType;
|
||||
const hasCargo =
|
||||
let hasCargo =
|
||||
(booking.bookingContainers?.length ?? 0) > 0 ||
|
||||
Number(booking.cargoTotalWeightVgm) > 0;
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Operations may return a booking asking for the CARGO to change (fewer or
|
||||
// more containers), not just the day. A resubmit whose payload restates the
|
||||
// cargo therefore starts the completion over: cancel the unpaid invoice
|
||||
// first (it throws if money is already recorded — cargo must not change
|
||||
// under a paid invoice), then wipe the persisted cargo so the fresh-
|
||||
// completion path below re-persists, re-prices and re-invoices from the
|
||||
// payload. A resubmit without cargo keeps today's day-only behavior.
|
||||
const restatesCargo = Boolean(
|
||||
dto.containers?.length || dto.bulkLines?.length,
|
||||
);
|
||||
if (hasCargo && restatesCargo) {
|
||||
await this.invoiceService.cancelUnpaidInvoiceForBooking(booking.id);
|
||||
await this.bookingsRepository.deleteContainers(booking.id);
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
cargoTotalWeightVgm: 0,
|
||||
} as never);
|
||||
hasCargo = false;
|
||||
}
|
||||
|
||||
// EXPORT rides whole or not at all (no split concept): the chosen day must
|
||||
// have a single open train that carries the whole booking. First completion
|
||||
// sizes from the dto's cargo; a changes-requested resubmit (cargo already
|
||||
@@ -1863,6 +1885,9 @@ export class ContractBookingService {
|
||||
async validateShipment(
|
||||
contractId: string,
|
||||
dto: CreateBookingUnderContractDto,
|
||||
// Completion/resubmit preview: the booking being completed must not clash
|
||||
// with its own persisted containers.
|
||||
excludeBookingId?: string,
|
||||
): Promise<{
|
||||
overweightLines: Array<{
|
||||
containerTypeCode: string;
|
||||
@@ -2024,6 +2049,7 @@ export class ContractBookingService {
|
||||
originYardId: route?.originYardId,
|
||||
destinationYardId: route?.destinationYardId,
|
||||
},
|
||||
excludeBookingId,
|
||||
);
|
||||
containerClashErrors = clashes.map(
|
||||
(c) =>
|
||||
|
||||
@@ -1145,8 +1145,11 @@ export class ContractsController {
|
||||
validateShipment(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: CreateBookingUnderContractDto,
|
||||
// Completion/resubmit preview: exclude this booking's own persisted
|
||||
// containers from the same-train clash check.
|
||||
@Query('bookingId') bookingId?: string,
|
||||
) {
|
||||
return this.contractBookingService.validateShipment(id, dto);
|
||||
return this.contractBookingService.validateShipment(id, dto, bookingId);
|
||||
}
|
||||
|
||||
@Get(':id/capacity')
|
||||
|
||||
@@ -31,6 +31,19 @@ export function paymentDrainMs(): number {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO timestamp of the end of a pay window's drain tail, for client display
|
||||
* (the "payment processing" countdown). Null in ⇒ null out.
|
||||
*/
|
||||
export function paymentDrainEndsAtIso(
|
||||
deadline: Date | string | null | undefined,
|
||||
): string | null {
|
||||
if (deadline == null) return null;
|
||||
const ms = new Date(deadline).getTime();
|
||||
if (!Number.isFinite(ms)) return null;
|
||||
return new Date(ms + paymentDrainMs()).toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* A pay window AND its drain tail have closed.
|
||||
*
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Server, Socket } from 'socket.io';
|
||||
|
||||
import { WsAuthService } from '../notification-inbox/ws-auth.service';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { paymentDrainEndsAtIso } from './booking-batch.constants';
|
||||
|
||||
/**
|
||||
* Server → client push for booking-window state changes. Same handshake model
|
||||
@@ -61,6 +62,7 @@ export class BookingWindowGateway implements OnGatewayConnection {
|
||||
windowClosesAt: schedule.windowClosesAt?.toISOString() ?? null,
|
||||
docReviewEndsAt: schedule.docReviewEndsAt?.toISOString() ?? null,
|
||||
paymentPhaseEndsAt: schedule.paymentPhaseEndsAt?.toISOString() ?? null,
|
||||
paymentDrainEndsAt: paymentDrainEndsAtIso(schedule.paymentPhaseEndsAt),
|
||||
scheduledDepartureDate: schedule.scheduledDepartureDate?.toISOString() ?? null,
|
||||
};
|
||||
this.server.emit(BOOKING_WINDOW_WS_EVENTS.PHASE, payload);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
DEFAULT_PAYMENT_DRAIN_MINUTES,
|
||||
paymentDrainEndsAtIso,
|
||||
paymentDrainMs,
|
||||
payWindowLapsed,
|
||||
} from "./booking-batch.constants";
|
||||
@@ -64,4 +65,16 @@ describe("payWindowLapsed — pay-window drain tail", () => {
|
||||
expect(paymentDrainMs()).toBe(DEFAULT_PAYMENT_DRAIN_MINUTES * MIN);
|
||||
}
|
||||
});
|
||||
|
||||
it("paymentDrainEndsAtIso reports deadline + drain, null/garbage-safe", () => {
|
||||
expect(paymentDrainEndsAtIso(deadline)).toBe(
|
||||
new Date(at(DEFAULT_PAYMENT_DRAIN_MINUTES * MIN)).toISOString(),
|
||||
);
|
||||
expect(paymentDrainEndsAtIso(deadline.toISOString())).toBe(
|
||||
new Date(at(DEFAULT_PAYMENT_DRAIN_MINUTES * MIN)).toISOString(),
|
||||
);
|
||||
expect(paymentDrainEndsAtIso(null)).toBeNull();
|
||||
expect(paymentDrainEndsAtIso(undefined)).toBeNull();
|
||||
expect(paymentDrainEndsAtIso("not-a-date")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -153,6 +153,7 @@ import {
|
||||
DEFAULT_CONTAINER_WAGON_CAPACITY_TONS,
|
||||
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||
DEFAULT_CONTAINER_WAGON_TARE_TONS,
|
||||
paymentDrainEndsAtIso,
|
||||
} from './booking-batch.constants';
|
||||
import { orderConsistWagons } from './consist-order.util';
|
||||
import {
|
||||
@@ -1546,23 +1547,10 @@ export class TrainSchedulingService {
|
||||
}
|
||||
: globalCfg;
|
||||
|
||||
// Staff cannot schedule inside the lead window — there must be room for a
|
||||
// booking window before departure. IMPORT/DOMESTIC lead is in whole EAT
|
||||
// days (lead 3, today 11th → first allowed departure is the 14th); EXPORT
|
||||
// lead is in hours (24h = 1 day ahead). Checked against the schedule's OWN
|
||||
// lead, so a custom lead is honoured rather than rejected by the global one.
|
||||
const earliest = earliestSchedulableDeparture(direction, windowCfg, new Date());
|
||||
if (departure.getTime() < earliest.getTime()) {
|
||||
const detail =
|
||||
direction === 'EXPORT'
|
||||
? `at least ${windowCfg.exportBookingLeadHours} hour(s) ahead`
|
||||
: `at least ${windowCfg.importWindowLeadDays} day(s) ahead`;
|
||||
throw new BadRequestException(
|
||||
`Departure ${departure.toISOString()} is inside the booking lead window; ` +
|
||||
`${direction === 'EXPORT' ? 'export' : 'import'} trains must be scheduled ${detail} ` +
|
||||
`(earliest ${earliest.toISOString()})`,
|
||||
);
|
||||
}
|
||||
// Short-notice trains are allowed: a departure inside the booking lead
|
||||
// window is NOT rejected — the window just opens immediately (opensAt is
|
||||
// clamped to `now` below) instead of waiting out a lead that has already
|
||||
// passed. Only `updateScheduleDate` still enforces the lead floor.
|
||||
|
||||
// Freeze the rule this schedule is born with. A later global-rules edit
|
||||
// only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an
|
||||
@@ -1577,6 +1565,11 @@ export class TrainSchedulingService {
|
||||
...ruleSnapshot,
|
||||
...computeImportWindowTimes(departure, windowCfg, new Date()),
|
||||
};
|
||||
// Inside-lead departure (e.g. a huge configured lead): the raw open lands
|
||||
// in the past — clamp it to `now` so the window tick opens it immediately.
|
||||
if (computedTimes.windowOpensAt.getTime() < Date.now()) {
|
||||
computedTimes.windowOpensAt = new Date();
|
||||
}
|
||||
if (
|
||||
computedTimes.windowOpensAt.getTime() >= computedTimes.windowClosesAt.getTime()
|
||||
) {
|
||||
@@ -6948,6 +6941,7 @@ export class TrainSchedulingService {
|
||||
windowClosesAt: r.window_closes_at,
|
||||
docReviewEndsAt: r.doc_review_ends_at,
|
||||
paymentPhaseEndsAt: r.payment_phase_ends_at,
|
||||
paymentDrainEndsAt: paymentDrainEndsAtIso(r.payment_phase_ends_at),
|
||||
bookingWindowStatus: r.booking_window_status,
|
||||
bookingCycleNo: r.booking_cycle_no,
|
||||
departureDate: r.scheduled_departure_date,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,7 @@ import {
|
||||
Wallet,
|
||||
LifeBuoy,
|
||||
TrainFront,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
@@ -54,6 +55,7 @@ import BookingContractPage from "./pages/bookings/BookingContractPage";
|
||||
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
||||
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
||||
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
||||
import WagonCancellationsPage from "./pages/bookings/WagonCancellationsPage";
|
||||
import ContractRequestsPage from "./pages/contracts/ContractRequestsPage";
|
||||
import ContractRequestDetailPage from "./pages/contracts/ContractRequestDetailPage";
|
||||
import ContractViewPage from "./pages/contracts/ContractViewPage";
|
||||
@@ -184,6 +186,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <FileText />,
|
||||
permission: FREIGHT_PERMS.bookings.view,
|
||||
},
|
||||
{
|
||||
label: "Wagon cancellations",
|
||||
href: "/dashboard/wagon-cancellations",
|
||||
icon: <XCircle />,
|
||||
permission: FREIGHT_PERMS.bookings.wagonCancellationView,
|
||||
},
|
||||
// Operations hub: per-shipment clearance-document review for services
|
||||
// WITHOUT customs clearing (self-clearance) — bookings only.
|
||||
{
|
||||
@@ -901,6 +909,16 @@ const App = () => {
|
||||
}
|
||||
/>
|
||||
<Route path="booking-requests/new" element={<RequirePermission permission={FREIGHT_PERMS.bookings.view}><NewBookingPage /></RequirePermission>} />
|
||||
<Route
|
||||
path="wagon-cancellations"
|
||||
element={
|
||||
<RequirePermission
|
||||
permission={FREIGHT_PERMS.bookings.wagonCancellationView}
|
||||
>
|
||||
<WagonCancellationsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="booking-requests/:id"
|
||||
element={
|
||||
|
||||
@@ -23,7 +23,13 @@ interface AuthEmployeePosition {
|
||||
permissions?: AuthPermission[];
|
||||
/** Some IAM payloads nest the position record instead of flattening its key. */
|
||||
position?: { id?: string; key?: string; name?: LocaleText };
|
||||
positionType?: { id?: string; key?: string; name?: LocaleText } | null;
|
||||
positionType?: {
|
||||
id?: string;
|
||||
key?: string;
|
||||
name?: LocaleText;
|
||||
/** Grants held by the TYPE — where admin-created positions keep theirs. */
|
||||
permissions?: AuthPermission[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface AuthEmployeeRecord {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Hash, Package, Ship, Weight, Clock } from "lucide-react";
|
||||
import { Hash, Package, Ship, Weight, Clock, TrainFront } from "lucide-react";
|
||||
import { Group, Stack, Text, Divider } from "@mantine/core";
|
||||
|
||||
import { cargoTonsAndItems } from "@/utils/cargoWeight";
|
||||
@@ -50,6 +50,21 @@ export function BookingFactsCard({ booking }: BookingFactsCardProps) {
|
||||
},
|
||||
{ icon: Clock, label: "Last Updated", value: formatDate(booking.updatedAt) },
|
||||
];
|
||||
// Allocated train facts — only once the booking rides a schedule.
|
||||
if (booking.trainSchedule?.trainNumber || booking.trainSchedule?.reference) {
|
||||
facts.splice(1, 0, {
|
||||
icon: TrainFront,
|
||||
label: "Allocated Train",
|
||||
value: [
|
||||
booking.trainSchedule.trainNumber
|
||||
? `Train ${booking.trainSchedule.trainNumber}`
|
||||
: null,
|
||||
booking.trainSchedule.reference ?? null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard icon={Hash} title="Booking Details" accent="cyan">
|
||||
|
||||
@@ -144,4 +144,10 @@ export interface BookingDetailView {
|
||||
bookingContainers?: BookingContainerView[];
|
||||
reviewNotes?: BookingReviewNoteView[];
|
||||
files?: BookingFileView[];
|
||||
/** The allocated train, present once the booking is placed on a schedule. */
|
||||
trainSchedule?: {
|
||||
trainNumber: string | null;
|
||||
reference: string | null;
|
||||
scheduledDepartureDate: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Alert, Button, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
import { DateInput } from "@mantine/dates";
|
||||
import { AlertTriangle, Send } from "lucide-react";
|
||||
import { AlertTriangle, Pencil, Send } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import toast from "react-hot-toast";
|
||||
@@ -16,6 +16,9 @@ export interface BookingChangesRequestedAlertProps {
|
||||
scheduledDate?: string | null;
|
||||
/** GL Ethiopia owns customs bookings, so only they get the resubmit control. */
|
||||
canResubmit: boolean;
|
||||
/** Completion-form route for editing the cargo before resubmitting —
|
||||
* rendered only for resubmit-capable users when provided. */
|
||||
editHref?: string;
|
||||
onResubmitted?: () => void;
|
||||
}
|
||||
|
||||
@@ -33,6 +36,7 @@ export function BookingChangesRequestedAlert({
|
||||
note,
|
||||
scheduledDate,
|
||||
canResubmit,
|
||||
editHref,
|
||||
onResubmitted,
|
||||
}: BookingChangesRequestedAlertProps) {
|
||||
const [day, setDay] = useState<Date | null>(
|
||||
@@ -122,6 +126,18 @@ export function BookingChangesRequestedAlert({
|
||||
>
|
||||
Resubmit to Operations
|
||||
</Button>
|
||||
{editHref ? (
|
||||
<Button
|
||||
component={Link}
|
||||
to={editHref}
|
||||
variant="default"
|
||||
radius="md"
|
||||
size="sm"
|
||||
leftSection={<Pencil size={15} />}
|
||||
>
|
||||
Edit cargo & resubmit
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
@@ -225,7 +225,13 @@ export function ExportClearanceStepper({
|
||||
|
||||
<Stepper.Step
|
||||
label="Request transit assignee"
|
||||
description="Ask GL Djibouti to name the officer handling this shipment"
|
||||
// Description is the only part of a passed step that stays visible,
|
||||
// so it carries the assigned officer's name for GL Ethiopia.
|
||||
description={
|
||||
clearance.transitAssignee?.name
|
||||
? `Transit assignee: ${clearance.transitAssignee.name}`
|
||||
: "Ask GL Djibouti to name the officer handling this shipment"
|
||||
}
|
||||
icon={
|
||||
clearance.transitAssignee?.name ? (
|
||||
<CheckCircle2 size={14} />
|
||||
|
||||
@@ -961,7 +961,7 @@ export default function GlCreateBookingForm() {
|
||||
// modal falls back to the contract unit-rate estimate while it loads.
|
||||
const validateShipmentMutation = useMutation({
|
||||
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
|
||||
contractsService.validateShipment(id ?? "", dto),
|
||||
contractsService.validateShipment(id ?? "", dto, completeBookingId),
|
||||
});
|
||||
const validation = validateShipmentMutation.data ?? null;
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ interface WindowRow {
|
||||
windowClosesAt: string | null;
|
||||
docReviewEndsAt: string | null;
|
||||
paymentPhaseEndsAt: string | null;
|
||||
/** End of the payment drain tail — pending payments may settle until then. */
|
||||
paymentDrainEndsAt?: string | null;
|
||||
bookingWindowStatus: string;
|
||||
bookingCycleNo: number;
|
||||
departureDate: string;
|
||||
@@ -94,16 +96,29 @@ const COUNTDOWN_TEXT: Partial<
|
||||
PRE_WINDOW: { label: "Opens in", expiredText: "Opening now…" },
|
||||
OPEN: { label: "Closes in", expiredText: "Review starting…" },
|
||||
DOC_REVIEW: { label: "Doc review ends in", expiredText: "Payment starting…" },
|
||||
PAYMENT: { label: "Payment ends in", expiredText: "Closing…" },
|
||||
PAYMENT: { label: "Payment ends in", expiredText: "Finalizing…" },
|
||||
};
|
||||
|
||||
function phaseCountdown(
|
||||
w: WindowRow,
|
||||
): { label: string; deadline: string; expiredText: string } | null {
|
||||
function phaseCountdown(w: WindowRow): {
|
||||
label: string;
|
||||
deadline: string;
|
||||
expiredText: string;
|
||||
graceDeadline?: string | null;
|
||||
graceLabel?: string;
|
||||
} | null {
|
||||
const state = bookingWindowUiState(w);
|
||||
const text = COUNTDOWN_TEXT[state.kind];
|
||||
if (!state.countdownTo || !text) return null;
|
||||
return { ...text, deadline: state.countdownTo };
|
||||
// Once the pay deadline lapses, pending payments still settle during the
|
||||
// drain tail — count it down as "processing" instead of a stale "closing".
|
||||
const grace =
|
||||
state.kind === "PAYMENT" && w.paymentDrainEndsAt
|
||||
? {
|
||||
graceDeadline: w.paymentDrainEndsAt,
|
||||
graceLabel: "Processing payments — closes in",
|
||||
}
|
||||
: undefined;
|
||||
return { ...text, deadline: state.countdownTo, ...grace };
|
||||
}
|
||||
|
||||
/** Badge label + Mantine color per UI state — same state the countdown uses. */
|
||||
@@ -225,6 +240,8 @@ function WindowCard({ w }: { w: WindowRow }) {
|
||||
deadline={cd.deadline}
|
||||
label={cd.label}
|
||||
expiredText={cd.expiredText}
|
||||
graceDeadline={cd.graceDeadline}
|
||||
graceLabel={cd.graceLabel}
|
||||
size="xs"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -357,7 +357,14 @@ export function PhasedClearanceActionPanel({
|
||||
|
||||
<Stepper.Step
|
||||
label="Request transit assignee"
|
||||
description="Ask GL Djibouti to name the officer handling this shipment"
|
||||
// Once the flow moves past this step its content collapses — the
|
||||
// description is the only slot that stays visible, so it carries
|
||||
// the assigned officer's name for GL Ethiopia.
|
||||
description={
|
||||
clearance.transitAssignee?.name
|
||||
? `Transit assignee: ${clearance.transitAssignee.name}`
|
||||
: "Ask GL Djibouti to name the officer handling this shipment"
|
||||
}
|
||||
icon={
|
||||
clearance.transitAssignee?.name ? (
|
||||
<CheckCircle2 size={14} />
|
||||
|
||||
@@ -30,6 +30,7 @@ interface WindowRow {
|
||||
windowClosesAt: string | null;
|
||||
docReviewEndsAt: string | null;
|
||||
paymentPhaseEndsAt: string | null;
|
||||
paymentDrainEndsAt?: string | null;
|
||||
bookingWindowStatus: string;
|
||||
bookingCycleNo: number;
|
||||
departureDate: string;
|
||||
@@ -55,6 +56,7 @@ function applyEvent<T extends WindowRow>(row: T, event: BookingWindowPhaseEvent)
|
||||
windowClosesAt: event.windowClosesAt,
|
||||
docReviewEndsAt: event.docReviewEndsAt,
|
||||
paymentPhaseEndsAt: event.paymentPhaseEndsAt,
|
||||
paymentDrainEndsAt: event.paymentDrainEndsAt,
|
||||
departureDate: event.scheduledDepartureDate ?? row.departureDate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,6 +53,10 @@ export const FREIGHT_PERMS = {
|
||||
finalizeClearance: "edr_freight_app:bookings:finalize_clearance",
|
||||
docReviewAlert: "edr_freight_app:bookings:doc_review_alert",
|
||||
governmentExpedite: "edr_freight_app:bookings:government_expedite",
|
||||
wagonCancellationView: "edr_freight_app:bookings:wagon_cancellation_view",
|
||||
wagonCancellationVoid: "edr_freight_app:bookings:wagon_cancellation_void",
|
||||
wagonCancellationRebook:
|
||||
"edr_freight_app:bookings:wagon_cancellation_rebook",
|
||||
},
|
||||
contracts: {
|
||||
view: "edr_freight_app:contracts:view",
|
||||
@@ -382,6 +386,14 @@ export function getPermissionKeys(user: AuthUser | null | undefined): string[] {
|
||||
for (const p of pos.permissions ?? []) {
|
||||
if (p.key) keys.add(p.key);
|
||||
}
|
||||
// Positions created through the admin UI keep their grants on the
|
||||
// position TYPE, not the position — miss these and such staff resolve to
|
||||
// zero permissions and every gated route rejects them. `/api/me` folds
|
||||
// them into the position's permission list, but older payloads may still
|
||||
// carry them separately.
|
||||
for (const p of pos.positionType?.permissions ?? []) {
|
||||
if (p.key) keys.add(p.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...keys];
|
||||
@@ -597,7 +609,9 @@ export function canViewScheduling(user: AuthUser | null | undefined): boolean {
|
||||
}
|
||||
|
||||
/** Any train-scheduling write action (create / update / cancel / reschedule). */
|
||||
export function canManageScheduling(user: AuthUser | null | undefined): boolean {
|
||||
export function canManageScheduling(
|
||||
user: AuthUser | null | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
hasPermission(user, FREIGHT_PERMS.trainScheduling.create) ||
|
||||
hasPermission(user, FREIGHT_PERMS.trainScheduling.update) ||
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
import {
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { DateInput } from "@mantine/dates";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Search, XCircle } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { api } from "@/auth/http";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import { toDayString } from "@/hooks/useListControls";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
type WagonCancellationStatus =
|
||||
| "FEE_PENDING"
|
||||
| "CREDIT_AVAILABLE"
|
||||
| "REBOOKED"
|
||||
| "WITHDRAWN"
|
||||
| "EXPIRED";
|
||||
|
||||
interface WagonCancellation {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
rebookedBookingId?: string | null;
|
||||
wagonsCancelled: number;
|
||||
weightTons: number;
|
||||
creditAmount: number;
|
||||
feeAmount: number;
|
||||
feeCurrency: string;
|
||||
feeInvoiceId?: string | null;
|
||||
feePaidAt?: string | null;
|
||||
status: WagonCancellationStatus;
|
||||
reason?: string | null;
|
||||
rebookedAt?: string | null;
|
||||
createdAt: string;
|
||||
booking?: { id: string; reference: string; company?: { name: string } };
|
||||
rebookedBooking?: { id: string; reference: string };
|
||||
feeInvoice?: { invoiceNumber: string; status: string };
|
||||
}
|
||||
|
||||
interface WagonCancellationListResponse {
|
||||
items: WagonCancellation[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
const STATUS_CHIP: Record<
|
||||
WagonCancellationStatus,
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
FEE_PENDING: { label: "Fee pending", color: "yellow" },
|
||||
CREDIT_AVAILABLE: { label: "Credit available", color: "edr-green" },
|
||||
REBOOKED: { label: "Rebooked", color: "indigo" },
|
||||
WITHDRAWN: { label: "Withdrawn", color: "gray" },
|
||||
EXPIRED: { label: "Expired", color: "red" },
|
||||
};
|
||||
|
||||
const STATUS_FILTER_OPTIONS = (
|
||||
Object.keys(STATUS_CHIP) as WagonCancellationStatus[]
|
||||
).map((s) => ({ value: s, label: STATUS_CHIP[s].label }));
|
||||
|
||||
function StatusChip({ status }: { status: WagonCancellationStatus }) {
|
||||
const chip = STATUS_CHIP[status] ?? { label: status, color: "gray" };
|
||||
return (
|
||||
<Badge
|
||||
color={chip.color}
|
||||
variant="light"
|
||||
size="sm"
|
||||
radius="md"
|
||||
tt="uppercase"
|
||||
fw={600}
|
||||
style={{ fontSize: "0.7rem", letterSpacing: "0.05em" }}
|
||||
>
|
||||
{chip.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null | undefined): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime())
|
||||
? "—"
|
||||
: d.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function formatAmount(amount: number, currency: string): string {
|
||||
return `${currency} ${Number(amount).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
})}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff view of partial wagon cancellations: every slice of capacity a
|
||||
* customer gave back, its cancellation fee, and where the credit went
|
||||
* (rebooked, still available, expired, or the request was voided).
|
||||
*/
|
||||
export default function WagonCancellationsPage() {
|
||||
const { user } = useAuth();
|
||||
const canVoid = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.bookings.wagonCancellationVoid,
|
||||
);
|
||||
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||
const [from, setFrom] = useState<Date | null>(null);
|
||||
const [to, setTo] = useState<Date | null>(null);
|
||||
const [voiding, setVoiding] = useState<WagonCancellation | null>(null);
|
||||
|
||||
const resetPage = () =>
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
|
||||
const filter = useMemo(
|
||||
() => ({
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
...(status ? { statuses: status } : {}),
|
||||
...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
|
||||
...(from ? { from: toDayString(from) } : {}),
|
||||
...(to ? { to: toDayString(to) } : {}),
|
||||
}),
|
||||
[pagination.pageIndex, pagination.pageSize, status, debouncedSearch, from, to],
|
||||
);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ["bookings", "wagon-cancellations", filter],
|
||||
queryFn: async () => {
|
||||
const res = await api.get<WagonCancellationListResponse>(
|
||||
"/bookings/wagon-cancellations/history",
|
||||
{ params: filter },
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
const rows = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
|
||||
const withdraw = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
api.post(`/bookings/wagon-cancellations/${id}/withdraw`),
|
||||
});
|
||||
|
||||
const columns: ColumnDef<WagonCancellation>[] = [
|
||||
{
|
||||
id: "requested",
|
||||
header: () => <span>Requested</span>,
|
||||
cell: ({ row }) => (
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatDate(row.original.createdAt)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "booking",
|
||||
header: () => <span>Booking</span>,
|
||||
cell: ({ row }) => (
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={`/dashboard/booking-requests/${row.original.bookingId}`}
|
||||
size="sm"
|
||||
fw={600}
|
||||
>
|
||||
{row.original.booking?.reference ?? row.original.bookingId}
|
||||
</Anchor>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "company",
|
||||
header: () => <span>Company</span>,
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">{row.original.booking?.company?.name ?? "—"}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "wagons",
|
||||
header: () => <span>Wagons</span>,
|
||||
cell: ({ row }) => <Text size="sm">{row.original.wagonsCancelled}</Text>,
|
||||
},
|
||||
{
|
||||
id: "fee",
|
||||
header: () => <span>Fee</span>,
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
{formatAmount(row.original.feeAmount, row.original.feeCurrency)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "credit",
|
||||
header: () => <span>Credit</span>,
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
{formatAmount(row.original.creditAmount, row.original.feeCurrency)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: () => <span>Status</span>,
|
||||
cell: ({ row }) => <StatusChip status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: "rebookedAs",
|
||||
header: () => <span>Rebooked as</span>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
if (!r.rebookedBookingId) return <Text size="sm">—</Text>;
|
||||
return (
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={`/dashboard/booking-requests/${r.rebookedBookingId}`}
|
||||
size="sm"
|
||||
>
|
||||
{r.rebookedBooking?.reference ?? r.rebookedBookingId}
|
||||
</Anchor>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span />,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
if (r.status !== "FEE_PENDING" || !canVoid) return null;
|
||||
return (
|
||||
<Group justify="flex-end" wrap="nowrap">
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => setVoiding(r)}
|
||||
>
|
||||
Void
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="Wagon cancellations"
|
||||
subtitle="Partial wagon cancellations — fees charged, credits held, and where each credit was rebooked"
|
||||
breadcrumbs={[
|
||||
{ label: "Bookings", href: "/dashboard/booking-requests" },
|
||||
{ label: "Wagon cancellations" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Stack gap="md">
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search booking ref or company…"
|
||||
leftSection={<Search size={15} />}
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.currentTarget.value);
|
||||
resetPage();
|
||||
}}
|
||||
w={260}
|
||||
radius="md"
|
||||
/>
|
||||
<Select
|
||||
placeholder="Status"
|
||||
data={STATUS_FILTER_OPTIONS}
|
||||
value={status}
|
||||
onChange={(v) => {
|
||||
setStatus(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
w={190}
|
||||
radius="md"
|
||||
/>
|
||||
<DateInput
|
||||
placeholder="From"
|
||||
value={from}
|
||||
onChange={(v) => {
|
||||
setFrom(v ? new Date(v) : null);
|
||||
resetPage();
|
||||
}}
|
||||
maxDate={to ?? undefined}
|
||||
clearable
|
||||
radius="md"
|
||||
style={{ minWidth: 140 }}
|
||||
/>
|
||||
<DateInput
|
||||
placeholder="To"
|
||||
value={to}
|
||||
onChange={(v) => {
|
||||
setTo(v ? new Date(v) : null);
|
||||
resetPage();
|
||||
}}
|
||||
minDate={from ?? undefined}
|
||||
clearable
|
||||
radius="md"
|
||||
style={{ minWidth: 140 }}
|
||||
/>
|
||||
<Button
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
onClick={() => {
|
||||
setStatus(null);
|
||||
setSearch("");
|
||||
setFrom(null);
|
||||
setTo(null);
|
||||
resetPage();
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
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>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(voiding)}
|
||||
onClose={() => setVoiding(null)}
|
||||
radius="md"
|
||||
title="Void this cancellation?"
|
||||
>
|
||||
{!voiding ? null : (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">
|
||||
{voiding.booking?.reference ?? voiding.bookingId} ·{" "}
|
||||
{voiding.wagonsCancelled} wagon(s) · fee{" "}
|
||||
{formatAmount(voiding.feeAmount, voiding.feeCurrency)}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
The pending fee is dropped and the wagons stay on the booking.
|
||||
Voiding can't be undone.
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
onClick={() => setVoiding(null)}
|
||||
>
|
||||
Keep it
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<XCircle size={15} />}
|
||||
loading={withdraw.isPending}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await withdraw.mutateAsync(voiding.id);
|
||||
toast.success("Cancellation voided");
|
||||
setVoiding(null);
|
||||
void refetch();
|
||||
} catch {
|
||||
// interceptor surfaces the reason
|
||||
}
|
||||
}}
|
||||
>
|
||||
Void
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -311,6 +311,7 @@ export default function ContractClearanceDetailPage() {
|
||||
note={clearance.linkedBookingReviewNote}
|
||||
scheduledDate={clearance.linkedBookingScheduledDate}
|
||||
canResubmit={canResubmitBooking}
|
||||
editHref={`/dashboard/contracts/${id}/bookings/${linkedBookingId}/complete?copyFrom=${linkedBookingId}`}
|
||||
onResubmitted={() => {
|
||||
void refetch();
|
||||
void refetchContract();
|
||||
|
||||
@@ -450,6 +450,35 @@ export default function ContractRequestDetailPage() {
|
||||
description={statusMeta.description}
|
||||
/>
|
||||
|
||||
{/* A contract resting in APPROVED means the automatic PDF generation on
|
||||
final approval failed — on success it moves straight to
|
||||
CONTRACT_READY. Offer the manual retry. */}
|
||||
{contract.status === "APPROVED" ? (
|
||||
<Alert
|
||||
color="orange"
|
||||
radius="md"
|
||||
icon={<AlertTriangle size={18} />}
|
||||
title="Contract document was not generated"
|
||||
>
|
||||
<Stack gap="sm" align="flex-start">
|
||||
<Text size="sm">
|
||||
All approvals are complete, but generating the contract PDF
|
||||
failed. Retry the generation below.
|
||||
</Text>
|
||||
<Button
|
||||
color="edr-green"
|
||||
size="compact-sm"
|
||||
radius="lg"
|
||||
leftSection={<RefreshCw size={15} />}
|
||||
loading={mutations.generateContract.isPending}
|
||||
onClick={() => mutations.generateContract.mutate()}
|
||||
>
|
||||
Regenerate contract
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{contract.status === "REJECTED" && contract.latestRejectionNote ? (
|
||||
<Alert
|
||||
color="red"
|
||||
|
||||
@@ -921,10 +921,10 @@ export default function TrainScheduleV2DetailPage() {
|
||||
<Title order={2} fw={700} style={{ color: "#0f172a" }}>
|
||||
{schedule.route?.name ?? "Train schedule"}
|
||||
</Title>
|
||||
{schedule.trainNumber ? (
|
||||
<Badge variant="light" color="#F2A516" radius="sm" style={{ fontWeight: 600 }}>
|
||||
{schedule.trainNumber}
|
||||
</Badge>
|
||||
{schedule.train?.trainName ? (
|
||||
<Text fw={700} style={{ color: "#0f172a" }}>
|
||||
{schedule.train.trainName}
|
||||
</Text>
|
||||
) : null}
|
||||
{schedule.train ? (
|
||||
<Text size="xs" c="dimmed" ff="monospace">
|
||||
@@ -932,6 +932,51 @@ export default function TrainScheduleV2DetailPage() {
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
{/* Voyage (train) number and trade direction — the two things
|
||||
operations identify a run by, so they read at a glance
|
||||
rather than as small badges among the rest. */}
|
||||
<Group gap="lg" align="center" wrap="wrap">
|
||||
{schedule.trainNumber ? (
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
|
||||
Voyage No.
|
||||
</Text>
|
||||
<Text
|
||||
ff="monospace"
|
||||
fw={800}
|
||||
lh={1.1}
|
||||
style={{ fontSize: 32, color: "#0f172a" }}
|
||||
>
|
||||
{schedule.trainNumber}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{schedule.direction ? (
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
|
||||
Direction
|
||||
</Text>
|
||||
<Text
|
||||
fw={800}
|
||||
lh={1.1}
|
||||
tt="uppercase"
|
||||
style={{
|
||||
fontSize: 32,
|
||||
letterSpacing: 0.5,
|
||||
color:
|
||||
schedule.direction === "IMPORT"
|
||||
? "#2E5B96"
|
||||
: schedule.direction === "EXPORT"
|
||||
? "#0A6F4D"
|
||||
: "#0f172a",
|
||||
}}
|
||||
>
|
||||
{schedule.direction}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Group>
|
||||
{(schedule.stops?.length ?? 0) >= 3 ||
|
||||
(schedule.bookings ?? []).some(
|
||||
(b) => b.tradeDirection === "DOMESTIC",
|
||||
|
||||
@@ -673,8 +673,16 @@ export const contractsService = {
|
||||
validateShipment: (
|
||||
id: string,
|
||||
payload: Freight.CreateBookingUnderContractDto,
|
||||
// Completion/resubmit preview: exclude this booking's own containers from
|
||||
// the same-train clash check.
|
||||
excludeBookingId?: string,
|
||||
) =>
|
||||
postContract<ShipmentValidation>(C.VALIDATE_SHIPMENT(id), payload),
|
||||
postContract<ShipmentValidation>(
|
||||
excludeBookingId
|
||||
? `${C.VALIDATE_SHIPMENT(id)}?bookingId=${excludeBookingId}`
|
||||
: C.VALIDATE_SHIPMENT(id),
|
||||
payload,
|
||||
),
|
||||
|
||||
/** Remaining bookable quantity per cargo line (GENERAL draw-down cap). */
|
||||
getCapacity: async (id: string): Promise<Freight.ContractCapacityLine[]> => {
|
||||
|
||||
@@ -47,6 +47,7 @@ function applyEvent(
|
||||
windowClosesAt: event.windowClosesAt,
|
||||
docReviewEndsAt: event.docReviewEndsAt,
|
||||
paymentPhaseEndsAt: event.paymentPhaseEndsAt,
|
||||
paymentDrainEndsAt: event.paymentDrainEndsAt,
|
||||
departureDate: event.scheduledDepartureDate ?? row.departureDate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,16 +68,29 @@ const COUNTDOWN_TEXT: Partial<
|
||||
PRE_WINDOW: { label: "Booking opens in", expiredText: "Booking opening now…" },
|
||||
OPEN: { label: "Window closes in", expiredText: "Document review starting…" },
|
||||
DOC_REVIEW: { label: "Document review ends in", expiredText: "Payment starting…" },
|
||||
PAYMENT: { label: "Payment due in", expiredText: "Payment window closing…" },
|
||||
PAYMENT: { label: "Payment due in", expiredText: "Finalizing payments…" },
|
||||
};
|
||||
|
||||
function phaseCountdown(
|
||||
w: MyBookingWindow,
|
||||
): { label: string; deadline: string; expiredText: string } | null {
|
||||
function phaseCountdown(w: MyBookingWindow): {
|
||||
label: string;
|
||||
deadline: string;
|
||||
expiredText: string;
|
||||
graceDeadline?: string | null;
|
||||
graceLabel?: string;
|
||||
} | null {
|
||||
const state = bookingWindowUiState(w);
|
||||
const text = COUNTDOWN_TEXT[state.kind];
|
||||
if (!state.countdownTo || !text) return null;
|
||||
return { ...text, deadline: state.countdownTo };
|
||||
// Once the pay deadline lapses, pending payments still settle during the
|
||||
// drain tail — count it down as "processing" instead of a stale "closing".
|
||||
const grace =
|
||||
state.kind === "PAYMENT" && w.paymentDrainEndsAt
|
||||
? {
|
||||
graceDeadline: w.paymentDrainEndsAt,
|
||||
graceLabel: "Payment processing — closes in",
|
||||
}
|
||||
: undefined;
|
||||
return { ...text, deadline: state.countdownTo, ...grace };
|
||||
}
|
||||
|
||||
function Pill({
|
||||
@@ -336,6 +349,8 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
|
||||
deadline={cd.deadline}
|
||||
label={cd.label}
|
||||
expiredText={cd.expiredText}
|
||||
graceDeadline={cd.graceDeadline}
|
||||
graceLabel={cd.graceLabel}
|
||||
size="xs"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { AlertCircle, Send, XCircle } from "lucide-react";
|
||||
import {
|
||||
AlertCircle,
|
||||
CalendarDays,
|
||||
ChevronRight,
|
||||
Package,
|
||||
Pencil,
|
||||
Send,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { PriceChangeModal } from "@/pages/bookings/resubmit/PriceChangeModal";
|
||||
@@ -25,13 +37,55 @@ import { CompanyInfoCard } from "./components/CompanyInfoCard";
|
||||
import { ContainersCard } from "./components/ContainersCard";
|
||||
import { ContractInfoCard } from "./components/ContractInfoCard";
|
||||
import { ActionRequiredBanner, MutationErrors } from "./components/Notices";
|
||||
import { PageHeader } from "./components/PageHeader";
|
||||
import { HeaderButton, PageHeader } from "./components/PageHeader";
|
||||
import { EstimateCard } from "./components/pricing";
|
||||
import { ScheduleCard } from "./components/ScheduleCard";
|
||||
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
|
||||
import { StatusHero } from "./components/StatusHero";
|
||||
import { SupportCard } from "./components/SupportCard";
|
||||
|
||||
/** Row linking straight to one section of the edit-booking form. */
|
||||
function EditLink({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
onClick,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<UnstyledButton
|
||||
onClick={onClick}
|
||||
p="sm"
|
||||
style={{
|
||||
border: "1px solid #E6ECF2",
|
||||
borderRadius: 12,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<Box mt={2} c="#0A6F4D">
|
||||
{icon}
|
||||
</Box>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fz={13.5} fw={700} c="#10202F">
|
||||
{title}
|
||||
</Text>
|
||||
<Text fz={12} c="#6B7C8E" mt={2}>
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box mt={2} c="#9AA8B5">
|
||||
<ChevronRight size={16} />
|
||||
</Box>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail-page view for a booking staff returned with CHANGES_REQUESTED.
|
||||
*
|
||||
@@ -64,8 +118,9 @@ export function ChangesRequestedView({
|
||||
null) as Freight.PricingBreakdown | null;
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
// Customer-facing cancel endpoint — the plain /cancel route is staff-only.
|
||||
mutationFn: (reason: string) =>
|
||||
api.bookings.cancel.call({ id: booking.id, reason }),
|
||||
bookingsService.customerCancel(booking.id, reason),
|
||||
onSuccess: () => {
|
||||
setCancelDialogOpen(false);
|
||||
onBookingUpdated();
|
||||
@@ -76,17 +131,21 @@ export function ChangesRequestedView({
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
booking={booking}
|
||||
menuActions={{
|
||||
onCancel: () => setCancelDialogOpen(true),
|
||||
onSupport: () => navigate("/support"),
|
||||
}}
|
||||
actions={
|
||||
<HeaderButton
|
||||
red
|
||||
icon={<XCircle size={16} />}
|
||||
label="Cancel booking"
|
||||
onClick={() => setCancelDialogOpen(true)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<MutationErrors mutations={[...flow.mutations, cancelMutation]} />
|
||||
|
||||
<StatusHero booking={booking}>
|
||||
{booking.latestChangeRequestNote ? (
|
||||
<ActionRequiredBanner title="Review the requested changes, then resubmit.">
|
||||
<ActionRequiredBanner title="Review the requested changes, update the booking if needed, then resubmit.">
|
||||
{booking.latestChangeRequestNote}
|
||||
</ActionRequiredBanner>
|
||||
) : undefined}
|
||||
@@ -101,6 +160,41 @@ export function ChangesRequestedView({
|
||||
|
||||
<ContractInfoCard booking={booking} />
|
||||
|
||||
<SectionCard>
|
||||
<CardTitle>Fix your booking</CardTitle>
|
||||
<Text fz="12.5px" c="#6B7C8E" mt={4} mb="md">
|
||||
Staff asked for changes on this booking. Update whatever needs
|
||||
fixing below, then resubmit for review — the booking stays in
|
||||
place, no need to start over.
|
||||
</Text>
|
||||
<Stack gap={8}>
|
||||
<EditLink
|
||||
icon={<Package size={16} />}
|
||||
title="Cargo & containers"
|
||||
description="Add or remove containers, change container type, quantity or VGM — or for bulk cargo, change the commodity and tonnage."
|
||||
onClick={() =>
|
||||
navigate(`/bookings/${booking.id}/edit?section=cargo`)
|
||||
}
|
||||
/>
|
||||
<EditLink
|
||||
icon={<CalendarDays size={16} />}
|
||||
title="Schedule date"
|
||||
description="Pick a different departure day — only days with an open schedule on your route can be selected."
|
||||
onClick={() =>
|
||||
navigate(`/bookings/${booking.id}/edit?section=schedule`)
|
||||
}
|
||||
/>
|
||||
<EditLink
|
||||
icon={<Pencil size={16} />}
|
||||
title="Route, service & other details"
|
||||
description="Change the origin or destination yard, service type, trucking options or notes."
|
||||
onClick={() =>
|
||||
navigate(`/bookings/${booking.id}/edit?section=service`)
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard>
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
<CardTitle>Your documents</CardTitle>
|
||||
|
||||
@@ -24,7 +24,10 @@ import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { downloadStoredFile } from "@/services/files.service";
|
||||
import type { SubmitBookingResponse } from "@/services/bookings.service";
|
||||
import {
|
||||
bookingsService,
|
||||
type SubmitBookingResponse,
|
||||
} from "@/services/bookings.service";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { REQUIRED_DOC_FIELDS } from "./constants";
|
||||
@@ -116,8 +119,9 @@ export function DraftBookingView({
|
||||
});
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
// Customer-facing cancel endpoint — the plain /cancel route is staff-only.
|
||||
mutationFn: (reason: string) =>
|
||||
api.bookings.cancel.call({ id: booking.id, reason }),
|
||||
bookingsService.customerCancel(booking.id, reason),
|
||||
onSuccess: () => {
|
||||
setCancelDialogOpen(false);
|
||||
onBookingUpdated();
|
||||
@@ -157,17 +161,21 @@ export function DraftBookingView({
|
||||
<PageHeader
|
||||
booking={booking}
|
||||
actions={
|
||||
<HeaderButton
|
||||
dark
|
||||
icon={<Pencil size={16} />}
|
||||
label="Continue editing"
|
||||
onClick={() => navigate(`/bookings/${booking.id}/edit`)}
|
||||
/>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<HeaderButton
|
||||
dark
|
||||
icon={<Pencil size={16} />}
|
||||
label="Continue editing"
|
||||
onClick={() => navigate(`/bookings/${booking.id}/edit`)}
|
||||
/>
|
||||
<HeaderButton
|
||||
red
|
||||
icon={<XCircle size={16} />}
|
||||
label="Cancel"
|
||||
onClick={() => setCancelDialogOpen(true)}
|
||||
/>
|
||||
</Group>
|
||||
}
|
||||
menuActions={{
|
||||
onCancel: () => setCancelDialogOpen(true),
|
||||
onSupport: () => navigate("/support"),
|
||||
}}
|
||||
/>
|
||||
|
||||
<MutationErrors
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { Group, Tabs } from "@mantine/core";
|
||||
import { Button, Group, Modal, Stack, Tabs, Text } from "@mantine/core";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
Clock,
|
||||
CreditCard,
|
||||
FileText,
|
||||
LayoutGrid,
|
||||
Package,
|
||||
TrainFront,
|
||||
Truck,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
|
||||
@@ -28,6 +34,7 @@ import { MileSummaryCard } from "./components/MileSummaryCard";
|
||||
import { BodyGrid, PageShell } from "./components/layout";
|
||||
import {
|
||||
CancelledBanner,
|
||||
ActionRequiredBanner,
|
||||
ConsolidationPairedNotice,
|
||||
ConsolidationWaitingBanner,
|
||||
} from "./components/Notices";
|
||||
@@ -41,10 +48,33 @@ import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
|
||||
import { ShipmentTrackingCard } from "./components/ShipmentTrackingCard";
|
||||
import { StatusHero } from "./components/StatusHero";
|
||||
import { SupportCard } from "./components/SupportCard";
|
||||
import { WagonCancellationCard } from "./components/WagonCancellationCard";
|
||||
import { WagonsTab } from "./components/WagonsTab";
|
||||
import { fmtDate, isNegative, priceTotal } from "./utils";
|
||||
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
||||
import { useBookingPayment } from "@/pages/bookings/payments/useBookingPayment";
|
||||
|
||||
// Pre-payment statuses the customer may self-cancel from this view (free of
|
||||
// charge). DRAFT / CHANGES_REQUESTED render their own views and drafts can
|
||||
// simply be deleted; anything at or past payment must go through support.
|
||||
const CUSTOMER_CANCELLABLE_STATUSES = [
|
||||
"SUBMITTED",
|
||||
"PRICE_CHANGED_PENDING_CONFIRM",
|
||||
"PENDING_APPROVAL",
|
||||
"CONTRACT_READY",
|
||||
"OPERATION_REQUEST_PENDING",
|
||||
"SELECTED_FOR_BATCH",
|
||||
];
|
||||
|
||||
const cancelErrorMessage = (error: unknown) => {
|
||||
const data = (
|
||||
error as { response?: { data?: { message?: string | string[] } } }
|
||||
)?.response?.data;
|
||||
if (Array.isArray(data?.message)) return data.message.join(", ");
|
||||
if (data?.message) return data.message;
|
||||
return "Could not cancel the booking. Please try again.";
|
||||
};
|
||||
|
||||
export function ReadonlyBookingView({
|
||||
booking,
|
||||
onBookingUpdated,
|
||||
@@ -71,6 +101,23 @@ export function ReadonlyBookingView({
|
||||
// and handles redirect vs CAC Bank OTP.
|
||||
const pay = useBookingPayment(booking.id);
|
||||
|
||||
const [cancelOpen, setCancelOpen] = useState(false);
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: () => bookingsService.customerCancel(booking.id),
|
||||
onSuccess: () => {
|
||||
setCancelOpen(false);
|
||||
toast.success(
|
||||
"Your booking has been cancelled — no cancellation fee was charged.",
|
||||
{ duration: 6000 },
|
||||
);
|
||||
onBookingUpdated?.();
|
||||
},
|
||||
onError: (e) => toast.error(cancelErrorMessage(e)),
|
||||
});
|
||||
const canCancel =
|
||||
booking.paymentStatus !== "PAID" &&
|
||||
CUSTOMER_CANCELLABLE_STATUSES.includes(status);
|
||||
|
||||
const pricing = booking.pricingBreakdown;
|
||||
// A general contract is paid once it's FULLY_EXECUTED (signed) — it never
|
||||
// enters batch selection. A one-time booking can only pay once it's been
|
||||
@@ -117,6 +164,9 @@ export function ReadonlyBookingView({
|
||||
"DOCUMENTS_UNDER_REVIEW",
|
||||
"CLEARANCE_READY",
|
||||
"OPERATION_REQUESTED",
|
||||
// Operations returned the order — same card hosts the pick-a-new-day +
|
||||
// resubmit flow.
|
||||
"OPERATION_CHANGES_REQUESTED",
|
||||
].includes(status);
|
||||
// Paired: a consolidation partner was found and the booking resumed the normal
|
||||
// flow. Surface the "partner found" reassurance only in the early stages,
|
||||
@@ -124,13 +174,16 @@ export function ReadonlyBookingView({
|
||||
const showPairedNotice =
|
||||
!!booking.consolidationPartnerId &&
|
||||
["SUBMITTED", "PENDING_APPROVAL", "CHANGES_REQUESTED"].includes(status);
|
||||
// Wagons exist only after payment puts the booking on a train; before that
|
||||
// the tab would always be an empty state, so it stays hidden.
|
||||
const showWagonsTab = booking.paymentStatus === "PAID" && !isNegative(status);
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
booking={booking}
|
||||
actions={
|
||||
(canApproveDelivery || (canPay && !showCountdown)) && (
|
||||
(canApproveDelivery || (canPay && !showCountdown) || canCancel) && (
|
||||
<Group gap={8} wrap="nowrap">
|
||||
{canApproveDelivery && (
|
||||
<ApproveDeliveryButton bookingId={booking.id} />
|
||||
@@ -143,13 +196,17 @@ export function ReadonlyBookingView({
|
||||
onClick={pay.open}
|
||||
/>
|
||||
)}
|
||||
{canCancel && (
|
||||
<HeaderButton
|
||||
red
|
||||
icon={<XCircle size={16} />}
|
||||
label="Cancel booking"
|
||||
onClick={() => setCancelOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
menuActions={{
|
||||
onRebook: canSelfRebook ? onRebook : undefined,
|
||||
onSupport: () => navigate("/support"),
|
||||
}}
|
||||
/>
|
||||
|
||||
{isNegative(status) ? (
|
||||
@@ -182,7 +239,14 @@ export function ReadonlyBookingView({
|
||||
priceLabel={pricing ? priceTotal(pricing) : undefined}
|
||||
/>
|
||||
) : (
|
||||
<StatusHero booking={booking} />
|
||||
<StatusHero booking={booking}>
|
||||
{status === "OPERATION_CHANGES_REQUESTED" &&
|
||||
booking.latestChangeRequestNote ? (
|
||||
<ActionRequiredBanner title="Operations requested changes — pick a new shipment day and resubmit.">
|
||||
{booking.latestChangeRequestNote}
|
||||
</ActionRequiredBanner>
|
||||
) : undefined}
|
||||
</StatusHero>
|
||||
)}
|
||||
|
||||
{showPairedNotice && <ConsolidationPairedNotice />}
|
||||
@@ -210,6 +274,11 @@ export function ReadonlyBookingView({
|
||||
<Tabs.Tab value="cargo" leftSection={<Package size={15} />}>
|
||||
Cargo
|
||||
</Tabs.Tab>
|
||||
{showWagonsTab && (
|
||||
<Tabs.Tab value="wagons" leftSection={<TrainFront size={15} />}>
|
||||
Wagons
|
||||
</Tabs.Tab>
|
||||
)}
|
||||
<Tabs.Tab value="logistics" leftSection={<Truck size={15} />}>
|
||||
Logistics
|
||||
</Tabs.Tab>
|
||||
@@ -257,6 +326,11 @@ export function ReadonlyBookingView({
|
||||
title="Consignment & Schedule"
|
||||
consignment
|
||||
/>
|
||||
{/* Renders only on PAID + paid + contract-backed bookings. */}
|
||||
<WagonCancellationCard
|
||||
booking={booking}
|
||||
onBookingUpdated={onBookingUpdated}
|
||||
/>
|
||||
<CompanyInfoCard booking={booking} />
|
||||
<SupportCard />
|
||||
</>
|
||||
@@ -269,6 +343,20 @@ export function ReadonlyBookingView({
|
||||
<CargoTab booking={booking} />
|
||||
</Tabs.Panel>
|
||||
|
||||
{showWagonsTab && (
|
||||
<Tabs.Panel value="wagons">
|
||||
<WagonsTab
|
||||
bookingId={booking.id}
|
||||
cancellable={
|
||||
booking.status === "PAID" &&
|
||||
booking.paymentStatus === "PAID" &&
|
||||
Boolean(booking.contractId)
|
||||
}
|
||||
onCancellationRequested={onBookingUpdated}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
)}
|
||||
|
||||
<Tabs.Panel value="logistics">
|
||||
<div className="flex flex-col gap-6">
|
||||
<BodyGrid
|
||||
@@ -313,6 +401,52 @@ export function ReadonlyBookingView({
|
||||
bill={pay.bill}
|
||||
onConfirm={pay.confirm}
|
||||
/>
|
||||
<Modal
|
||||
opened={cancelOpen}
|
||||
onClose={() => setCancelOpen(false)}
|
||||
title={
|
||||
<Text fw={800} fz={18} c="#10202F">
|
||||
Cancel this booking?
|
||||
</Text>
|
||||
}
|
||||
centered
|
||||
radius={16}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="#475569">
|
||||
You're about to cancel booking{" "}
|
||||
<Text span fw={700} c="#10202F">
|
||||
{booking.reference}
|
||||
</Text>
|
||||
. Since you haven't paid yet,{" "}
|
||||
<Text span fw={700}>
|
||||
no cancellation fee
|
||||
</Text>{" "}
|
||||
will be charged
|
||||
{status === "SELECTED_FOR_BATCH"
|
||||
? ", and your reserved wagon space will be released immediately"
|
||||
: ""}
|
||||
. This cannot be undone.
|
||||
</Text>
|
||||
<Group justify="flex-end" gap={8}>
|
||||
<Button
|
||||
variant="default"
|
||||
radius={10}
|
||||
onClick={() => setCancelOpen(false)}
|
||||
>
|
||||
Keep booking
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
radius={10}
|
||||
loading={cancelMutation.isPending}
|
||||
onClick={() => cancelMutation.mutate()}
|
||||
>
|
||||
Cancel booking
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
{viewer}
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface BookingContainerLineDetail {
|
||||
isOverweight?: boolean;
|
||||
overweightExcessTons?: number | string | null;
|
||||
containerNumber?: string | null;
|
||||
/** Size (ft) as stored on the line ("20"/"40") — the wagon-cancellation key. */
|
||||
containerSize?: string | null;
|
||||
containerType?: {
|
||||
code: string;
|
||||
label?: string | null;
|
||||
@@ -75,6 +77,12 @@ export type BookingDetail = Freight.IBooking & {
|
||||
reference: string;
|
||||
status: string;
|
||||
} | null;
|
||||
/** The allocated train, present once the booking is placed on a schedule. */
|
||||
trainSchedule?: {
|
||||
trainNumber: string | null;
|
||||
reference: string | null;
|
||||
scheduledDepartureDate: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -51,7 +51,12 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
||||
}
|
||||
|
||||
const summary =
|
||||
status === "CLEARANCE_READY" ? (
|
||||
status === "OPERATION_CHANGES_REQUESTED" ? (
|
||||
<Alert color="yellow" radius="md" icon={<Clock size={18} />}>
|
||||
Operations returned this order for changes. Update the booking details,
|
||||
pick a new shipment day and resubmit.
|
||||
</Alert>
|
||||
) : status === "CLEARANCE_READY" ? (
|
||||
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />}>
|
||||
{`${
|
||||
booking.customsClearingEnabled
|
||||
@@ -103,9 +108,11 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
||||
{summary}
|
||||
|
||||
<Text fz="12.5px" c="dimmed" mt="sm">
|
||||
{isBookAction
|
||||
? "Use “Book” to enter the cargo details and schedule your shipment."
|
||||
: `Use “${action?.label ?? "the action button"}” to manage your ${docNoun}.`}
|
||||
{status === "OPERATION_CHANGES_REQUESTED"
|
||||
? "Use “Change booking” to update the details and pick a new shipment day."
|
||||
: isBookAction
|
||||
? "Use “Book” to enter the cargo details and schedule your shipment."
|
||||
: `Use “${action?.label ?? "the action button"}” to manage your ${docNoun}.`}
|
||||
</Text>
|
||||
|
||||
{!isBookAction && (
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
import { ActionIcon, Button, Group, Menu, Stack, Text } from "@mantine/core";
|
||||
import {
|
||||
ArrowDownLeft,
|
||||
ArrowUpRight,
|
||||
Edit2,
|
||||
FileText,
|
||||
HelpCircle,
|
||||
MoreHorizontal,
|
||||
RefreshCw,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { Button, Group, Stack, Text } from "@mantine/core";
|
||||
import { ArrowDownLeft, ArrowUpRight } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
@@ -20,22 +11,12 @@ import {
|
||||
|
||||
import { bookingSubtitle, isDraftLike, isNegative } from "../utils";
|
||||
|
||||
export interface PageHeaderMenuActions {
|
||||
onViewContract?: () => void;
|
||||
onCancel?: () => void;
|
||||
onEdit?: () => void;
|
||||
onSupport?: () => void;
|
||||
onRebook?: () => void;
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
booking,
|
||||
actions,
|
||||
menuActions,
|
||||
}: {
|
||||
booking: Freight.IBooking;
|
||||
actions?: ReactNode;
|
||||
menuActions?: PageHeaderMenuActions;
|
||||
}) {
|
||||
const status = booking.status as string;
|
||||
const negative = isNegative(status);
|
||||
@@ -47,8 +28,6 @@ export function PageHeader({
|
||||
const pillText = negative ? "#A93226" : draft ? "#475569" : "#0A6F4D";
|
||||
const isExport = booking.tradeDirection === "EXPORT";
|
||||
|
||||
const hasMenu = menuActions && Object.values(menuActions).some(Boolean);
|
||||
|
||||
return (
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<Stack gap={8} miw={0}>
|
||||
@@ -83,66 +62,6 @@ export function PageHeader({
|
||||
|
||||
<Group gap={8} wrap="nowrap" align="center">
|
||||
{actions}
|
||||
{hasMenu && (
|
||||
<Menu shadow="md" radius={12} position="bottom-end">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={42}
|
||||
radius={10}
|
||||
aria-label="More options"
|
||||
>
|
||||
<MoreHorizontal size={18} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown miw={210}>
|
||||
{menuActions!.onViewContract && (
|
||||
<Menu.Item
|
||||
leftSection={<FileText size={15} />}
|
||||
onClick={menuActions!.onViewContract}
|
||||
>
|
||||
View contract
|
||||
</Menu.Item>
|
||||
)}
|
||||
{menuActions!.onEdit && (
|
||||
<Menu.Item
|
||||
leftSection={<Edit2 size={15} />}
|
||||
onClick={menuActions!.onEdit}
|
||||
>
|
||||
Edit
|
||||
</Menu.Item>
|
||||
)}
|
||||
{menuActions!.onSupport && (
|
||||
<Menu.Item
|
||||
leftSection={<HelpCircle size={15} />}
|
||||
onClick={menuActions!.onSupport}
|
||||
>
|
||||
Contact customer support
|
||||
</Menu.Item>
|
||||
)}
|
||||
{menuActions!.onRebook && (
|
||||
<Menu.Item
|
||||
leftSection={<RefreshCw size={15} />}
|
||||
onClick={menuActions!.onRebook}
|
||||
>
|
||||
Rebook similar schedule
|
||||
</Menu.Item>
|
||||
)}
|
||||
{menuActions!.onCancel && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<XCircle size={15} />}
|
||||
onClick={menuActions!.onCancel}
|
||||
>
|
||||
Cancel booking
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
@@ -154,6 +73,7 @@ export function HeaderButton({
|
||||
onClick,
|
||||
dark,
|
||||
green,
|
||||
red,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
@@ -161,6 +81,7 @@ export function HeaderButton({
|
||||
onClick?: () => void;
|
||||
dark?: boolean;
|
||||
green?: boolean;
|
||||
red?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
@@ -169,14 +90,14 @@ export function HeaderButton({
|
||||
disabled={disabled}
|
||||
leftSection={icon}
|
||||
radius={10}
|
||||
variant={green || dark ? "filled" : "default"}
|
||||
color={green ? "edr-green" : dark ? "#0C1A2B" : undefined}
|
||||
variant={green || dark ? "filled" : red ? "outline" : "default"}
|
||||
color={green ? "edr-green" : dark ? "#0C1A2B" : red ? "red" : undefined}
|
||||
styles={{
|
||||
root: { height: 42, paddingInline: 16 },
|
||||
label: {
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: green || dark ? "#fff" : "#10202F",
|
||||
color: green || dark ? "#fff" : red ? undefined : "#10202F",
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -65,6 +65,12 @@ export function ScheduleCard({
|
||||
const service = serviceTypeLabel(booking);
|
||||
const equipmentReturn =
|
||||
booking.equipmentReturn === "WITH_RETURN" ? "With return" : "Without return";
|
||||
const schedule = booking.trainSchedule;
|
||||
const trainLabel = schedule?.trainNumber
|
||||
? `Train ${schedule.trainNumber}${schedule.reference ? ` · ${schedule.reference}` : ""}`
|
||||
: schedule?.reference
|
||||
? `Schedule ${schedule.reference}`
|
||||
: "Track shipment";
|
||||
const assignedTrain: Row = booking.trainScheduleId
|
||||
? {
|
||||
label: "Assigned train",
|
||||
@@ -85,7 +91,7 @@ export function ScheduleCard({
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<MapPin size={13} /> Track shipment
|
||||
<MapPin size={13} /> {trainLabel}
|
||||
</button>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { CheckCircle2, Clock, CreditCard, TrainTrack } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import {
|
||||
bookingsService,
|
||||
type RequestWagonCancellationPayload,
|
||||
type WagonCancellation,
|
||||
type WagonCancellationPreview,
|
||||
} from "@/services/bookings.service";
|
||||
import { OperationDatePicker } from "@/pages/bookings/clearance";
|
||||
import { useFeeInvoicePayment } from "@/pages/bookings/payments/useBookingPayment";
|
||||
|
||||
import type { BookingDetail } from "../booking-detail-types";
|
||||
import { fmtDate } from "../utils";
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
import { PaymentMethodModal } from "./PaymentMethodModal";
|
||||
|
||||
// Same pill treatment as WagonsTab's STATUS_TONES so the page reads as one.
|
||||
const STATUS_TONES: Record<
|
||||
WagonCancellation["status"],
|
||||
{ bg: string; color: string; label: string }
|
||||
> = {
|
||||
FEE_PENDING: { bg: "#FFFBEB", color: "#92400E", label: "Fee pending" },
|
||||
CREDIT_AVAILABLE: { bg: "#EAF1FE", color: "#1E40AF", label: "Credit available" },
|
||||
REBOOKED: { bg: "#E8F5EF", color: "#0A6F4D", label: "Rebooked" },
|
||||
WITHDRAWN: { bg: "#F1F4F7", color: "#475569", label: "Withdrawn" },
|
||||
EXPIRED: { bg: "#FEF2F2", color: "#B91C1C", label: "Expired" },
|
||||
};
|
||||
|
||||
function StatusPill({ status }: { status: WagonCancellation["status"] }) {
|
||||
const tone = STATUS_TONES[status] ?? STATUS_TONES.WITHDRAWN;
|
||||
return (
|
||||
<Text
|
||||
component="span"
|
||||
fz={11}
|
||||
fw={700}
|
||||
px={9}
|
||||
py={3}
|
||||
style={{ borderRadius: 999, backgroundColor: tone.bg, color: tone.color }}
|
||||
>
|
||||
{tone.label}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const fmtMoney = (amount: number | string, currency: string) =>
|
||||
`${Number(amount).toLocaleString()} ${currency}`;
|
||||
|
||||
const apiErrorMessage = (error: unknown, fallback: string) => {
|
||||
const data = (
|
||||
error as { response?: { data?: { message?: string | string[] } } }
|
||||
)?.response?.data;
|
||||
if (Array.isArray(data?.message)) return data.message.join(", ");
|
||||
if (data?.message) return data.message;
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const th = { color: "#9AA8B5", fontSize: 11 } as const;
|
||||
|
||||
/**
|
||||
* Partial wagon cancellation on a PAID contract booking: request a cut (fee
|
||||
* previewed first), pay the cancellation fee, then rebook the freed credit
|
||||
* onto another shipment day — plus the booking's cancellation history.
|
||||
* Wagons leave the schedule at request time; the fee settles the credit.
|
||||
*/
|
||||
export function WagonCancellationCard({
|
||||
booking,
|
||||
onBookingUpdated,
|
||||
}: {
|
||||
booking: Freight.IBooking;
|
||||
onBookingUpdated?: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const status = booking.status as string;
|
||||
const eligible =
|
||||
status === "PAID" &&
|
||||
(booking.paymentStatus as string) === "PAID" &&
|
||||
!!booking.contractId;
|
||||
|
||||
const isBulk = booking.freightType === "BULK";
|
||||
const detail = booking as BookingDetail;
|
||||
// The entity field the API serializes on the detail read; not on the DTO type.
|
||||
const wagonsRequired = Number(
|
||||
(booking as { wagonsRequired?: number | string | null }).wagonsRequired ?? 0,
|
||||
);
|
||||
|
||||
// Live units per container size ("20"/"40"), summed across lines.
|
||||
const containerLines = useMemo(() => {
|
||||
const bySize = new Map<string, number>();
|
||||
for (const line of detail.bookingContainers ?? []) {
|
||||
const size =
|
||||
line.containerSize ??
|
||||
(line.containerType?.sizeFt != null
|
||||
? String(line.containerType.sizeFt)
|
||||
: null);
|
||||
if (!size) continue;
|
||||
bySize.set(size, (bySize.get(size) ?? 0) + Number(line.quantity ?? 0));
|
||||
}
|
||||
return [...bySize.entries()].map(([containerSize, quantity]) => ({
|
||||
containerSize,
|
||||
quantity,
|
||||
}));
|
||||
}, [detail.bookingContainers]);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
...api.bookings.listWagonCancellations.queryOptions({
|
||||
input: { bookingId: booking.id },
|
||||
}),
|
||||
enabled: eligible,
|
||||
});
|
||||
// History includes rows where this booking is the rebooked TARGET — only
|
||||
// rows this booking opened itself can be paid/withdrawn/rebooked from here.
|
||||
const rows = data?.items ?? [];
|
||||
const ownRows = rows.filter((r) => r.bookingId === booking.id);
|
||||
const openRow = ownRows.find((r) => r.status === "FEE_PENDING");
|
||||
const creditRow = ownRows.find((r) => r.status === "CREDIT_AVAILABLE");
|
||||
|
||||
const feePay = useFeeInvoicePayment(booking.id);
|
||||
|
||||
// ── Request modal state ──
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [wagons, setWagons] = useState<number | string>(1);
|
||||
const [cancelBySize, setCancelBySize] = useState<Record<string, number>>({});
|
||||
const [reason, setReason] = useState("");
|
||||
const [preview, setPreview] = useState<WagonCancellationPreview | null>(null);
|
||||
|
||||
const closeModal = () => {
|
||||
setModalOpen(false);
|
||||
setWagons(1);
|
||||
setCancelBySize({});
|
||||
setReason("");
|
||||
setPreview(null);
|
||||
};
|
||||
|
||||
const requestPayload = (): RequestWagonCancellationPayload | null => {
|
||||
const trimmedReason = reason.trim();
|
||||
if (isBulk) {
|
||||
const n = Number(wagons);
|
||||
if (!n || n <= 0) return null;
|
||||
return { wagons: n, ...(trimmedReason ? { reason: trimmedReason } : {}) };
|
||||
}
|
||||
const containers = containerLines
|
||||
.map((l) => ({
|
||||
containerSize: l.containerSize,
|
||||
quantity: cancelBySize[l.containerSize] ?? 0,
|
||||
}))
|
||||
.filter((c) => c.quantity > 0);
|
||||
if (!containers.length) return null;
|
||||
return { containers, ...(trimmedReason ? { reason: trimmedReason } : {}) };
|
||||
};
|
||||
const payload = requestPayload();
|
||||
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: (body: RequestWagonCancellationPayload) =>
|
||||
bookingsService.previewWagonCancellation(booking.id, body),
|
||||
onSuccess: setPreview,
|
||||
onError: (e) => {
|
||||
setPreview(null);
|
||||
toast.error(apiErrorMessage(e, "Could not calculate the fee. Please try again."));
|
||||
},
|
||||
});
|
||||
|
||||
const requestMutation = useMutation({
|
||||
mutationFn: (body: RequestWagonCancellationPayload) =>
|
||||
bookingsService.requestWagonCancellation(booking.id, body),
|
||||
onSuccess: () => {
|
||||
closeModal();
|
||||
toast.success(
|
||||
"Cancellation requested — pay the fee to release the wagons.",
|
||||
{ duration: 6000 },
|
||||
);
|
||||
void refetch();
|
||||
onBookingUpdated?.();
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(
|
||||
apiErrorMessage(e, "Could not request the cancellation. Please try again."),
|
||||
),
|
||||
});
|
||||
|
||||
const withdrawMutation = useMutation({
|
||||
mutationFn: () => bookingsService.withdrawWagonCancellation(openRow!.id),
|
||||
onSuccess: () => {
|
||||
toast.success("Cancellation withdrawn — the fee invoice was voided.");
|
||||
void refetch();
|
||||
onBookingUpdated?.();
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(
|
||||
apiErrorMessage(e, "Could not withdraw the cancellation. Please try again."),
|
||||
),
|
||||
});
|
||||
|
||||
const [rebookDate, setRebookDate] = useState("");
|
||||
const rebookMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
bookingsService.rebookWagonCancellation(creditRow!.id, {
|
||||
scheduledDate: rebookDate,
|
||||
}),
|
||||
onSuccess: ({ bookingId }) => {
|
||||
toast.success("Wagons rebooked — taking you to the new booking.", {
|
||||
duration: 6000,
|
||||
});
|
||||
navigate(`/bookings/${bookingId}`);
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(apiErrorMessage(e, "Could not rebook the wagons. Please try again.")),
|
||||
});
|
||||
|
||||
if (!eligible) return null;
|
||||
|
||||
return (
|
||||
<SectionCard>
|
||||
<Group justify="space-between" align="center" mb="sm">
|
||||
<CardTitle>Wagon Cancellation</CardTitle>
|
||||
{!openRow && !creditRow && (
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<TrainTrack size={16} />}
|
||||
onClick={() => setModalOpen(true)}
|
||||
>
|
||||
Cancel wagons
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{openRow ? (
|
||||
<Stack gap="sm">
|
||||
<Alert color="yellow" radius="md" icon={<Clock size={18} />}>
|
||||
A cancellation of {Number(openRow.wagonsCancelled)} wagon(s) is
|
||||
awaiting its fee of{" "}
|
||||
<Text span fw={700}>
|
||||
{fmtMoney(openRow.feeAmount, openRow.feeCurrency)}
|
||||
</Text>
|
||||
. The cancelled wagons have left the train. Pay the fee to unlock
|
||||
the rebooking credit, or withdraw the request to get the wagons
|
||||
back — withdrawing works only while the train still has free space
|
||||
for them.
|
||||
</Alert>
|
||||
<Group gap={8}>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<CreditCard size={16} />}
|
||||
onClick={feePay.open}
|
||||
>
|
||||
Pay cancellation fee
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
loading={withdrawMutation.isPending}
|
||||
onClick={() => withdrawMutation.mutate()}
|
||||
>
|
||||
Withdraw request
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : creditRow ? (
|
||||
<Stack gap="sm">
|
||||
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />}>
|
||||
{Number(creditRow.wagonsCancelled)} wagon(s) were released — a
|
||||
credit of{" "}
|
||||
<Text span fw={700}>
|
||||
{fmtMoney(creditRow.creditAmount, booking.paymentCurrency)}
|
||||
</Text>{" "}
|
||||
is available. Pick a shipment day to rebook them as a new paid
|
||||
booking (no further payment needed).
|
||||
</Alert>
|
||||
<OperationDatePicker
|
||||
bookingId={booking.id}
|
||||
value={rebookDate}
|
||||
onChange={setRebookDate}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
disabled={!rebookDate}
|
||||
loading={rebookMutation.isPending}
|
||||
onClick={() => rebookMutation.mutate()}
|
||||
>
|
||||
Rebook wagons
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : (
|
||||
<Text fz={13} c="#475569">
|
||||
Need fewer wagons than you paid for? Cancel part of this booking for
|
||||
a per-wagon fee — the freed freight amount becomes a credit you can
|
||||
rebook onto another shipment day.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{rows.length > 0 && (
|
||||
<Box style={{ overflowX: "auto" }} mt="md">
|
||||
<Table verticalSpacing={6} horizontalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th style={th}>Date</Table.Th>
|
||||
<Table.Th style={th}>Wagons</Table.Th>
|
||||
<Table.Th style={th}>Fee</Table.Th>
|
||||
<Table.Th style={th}>Status</Table.Th>
|
||||
<Table.Th style={th}>Rebooked as</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((r) => (
|
||||
<Table.Tr key={r.id}>
|
||||
<Table.Td>
|
||||
<Text fz={12.5} c="#475569">
|
||||
{fmtDate(r.createdAt)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={12.5} fw={700} c="#10202F">
|
||||
{Number(r.wagonsCancelled)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={12.5} c="#475569">
|
||||
{fmtMoney(r.feeAmount, r.feeCurrency)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<StatusPill status={r.status} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{r.rebookedBookingId ? (
|
||||
<Text
|
||||
component={Link}
|
||||
to={`/bookings/${r.rebookedBookingId}`}
|
||||
fz={12.5}
|
||||
fw={700}
|
||||
c="#0A6F4D"
|
||||
style={{ textDecoration: "underline" }}
|
||||
>
|
||||
{r.rebookedBooking?.reference ?? "View booking"}
|
||||
</Text>
|
||||
) : (
|
||||
<Text fz={12.5} c="#9AA8B5">
|
||||
—
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<PaymentMethodModal
|
||||
opened={feePay.modalOpen}
|
||||
onClose={feePay.close}
|
||||
amountLabel={
|
||||
openRow ? fmtMoney(openRow.feeAmount, openRow.feeCurrency) : undefined
|
||||
}
|
||||
currency={openRow?.feeCurrency}
|
||||
processing={feePay.processing}
|
||||
error={feePay.error}
|
||||
otp={feePay.otp}
|
||||
bill={feePay.bill}
|
||||
onConfirm={feePay.confirm}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
opened={modalOpen}
|
||||
onClose={closeModal}
|
||||
title={
|
||||
<Text fw={800} fz={18} c="#10202F">
|
||||
Cancel wagons
|
||||
</Text>
|
||||
}
|
||||
centered
|
||||
radius={16}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="#475569">
|
||||
Choose how much of booking{" "}
|
||||
<Text span fw={700} c="#10202F">
|
||||
{booking.reference}
|
||||
</Text>{" "}
|
||||
to cancel. A per-wagon fee applies; once it's paid the wagons
|
||||
are released and the freed amount becomes a rebooking credit. At
|
||||
least one wagon must remain — to cancel everything, cancel the
|
||||
whole booking instead.
|
||||
</Text>
|
||||
|
||||
{isBulk ? (
|
||||
<NumberInput
|
||||
label="Wagons to cancel"
|
||||
min={1}
|
||||
max={wagonsRequired > 1 ? wagonsRequired - 1 : undefined}
|
||||
allowDecimal={false}
|
||||
value={wagons}
|
||||
onChange={(v) => {
|
||||
setWagons(v);
|
||||
setPreview(null);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
containerLines.map((line) => (
|
||||
<NumberInput
|
||||
key={line.containerSize}
|
||||
label={`${line.containerSize}ft containers to cancel`}
|
||||
description={`${line.quantity} on this booking`}
|
||||
min={0}
|
||||
max={line.quantity}
|
||||
allowDecimal={false}
|
||||
value={cancelBySize[line.containerSize] ?? 0}
|
||||
onChange={(v) => {
|
||||
setCancelBySize((prev) => ({
|
||||
...prev,
|
||||
[line.containerSize]: Number(v) || 0,
|
||||
}));
|
||||
setPreview(null);
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
label="Reason (optional)"
|
||||
placeholder="Why are these wagons no longer needed?"
|
||||
autosize
|
||||
minRows={2}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
{preview && (
|
||||
<Alert color="blue" radius="md">
|
||||
<Text fz={13}>
|
||||
Cancelling{" "}
|
||||
<Text span fw={700}>
|
||||
{preview.wagons} wagon(s)
|
||||
</Text>{" "}
|
||||
(~{preview.weightTons} t) costs a fee of{" "}
|
||||
<Text span fw={700}>
|
||||
{fmtMoney(preview.feeAmount, preview.feeCurrency)}
|
||||
</Text>{" "}
|
||||
({fmtMoney(preview.feePerWagon, preview.feeCurrency)} per
|
||||
wagon) and frees a rebooking credit of{" "}
|
||||
<Text span fw={700}>
|
||||
{fmtMoney(preview.creditAmount, booking.paymentCurrency)}
|
||||
</Text>
|
||||
.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" gap={8}>
|
||||
<Button
|
||||
variant="default"
|
||||
radius={10}
|
||||
disabled={!payload}
|
||||
loading={previewMutation.isPending}
|
||||
onClick={() => payload && previewMutation.mutate(payload)}
|
||||
>
|
||||
Calculate fee
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
radius={10}
|
||||
disabled={!payload}
|
||||
loading={requestMutation.isPending}
|
||||
onClick={() => payload && requestMutation.mutate(payload)}
|
||||
>
|
||||
Request cancellation
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,792 @@
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Modal,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Container,
|
||||
Gauge,
|
||||
MapPin,
|
||||
Package,
|
||||
Route,
|
||||
Scale,
|
||||
TrainFront,
|
||||
TrainTrack,
|
||||
} from "lucide-react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import {
|
||||
bookingsService,
|
||||
type BookingWagonAllocation,
|
||||
type WagonCancellation,
|
||||
type WagonCancellationPreview,
|
||||
} from "@/services/bookings.service";
|
||||
import { api } from "@/services/api";
|
||||
|
||||
import { fmtDate, fmtWeight } from "../utils";
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
|
||||
const CANCEL_TONES: Record<
|
||||
string,
|
||||
{ bg: string; color: string; border: string; label: string; hint: string }
|
||||
> = {
|
||||
FEE_PENDING: {
|
||||
bg: "#FFFBEB",
|
||||
color: "#92400E",
|
||||
border: "#FDE68A",
|
||||
label: "Cancelled — fee unpaid",
|
||||
hint: "These wagons left the train. Pay the cancellation fee to turn them into a rebooking credit, or withdraw to get them back (needs free space).",
|
||||
},
|
||||
CREDIT_AVAILABLE: {
|
||||
bg: "#E6F7F2",
|
||||
color: "#0A6F4D",
|
||||
border: "#B7E6D6",
|
||||
label: "Cancelled — credit ready",
|
||||
hint: "Fee paid. Pick a new shipment day in the wagon cancellation card to rebook these — no new freight charge.",
|
||||
},
|
||||
REBOOKED: {
|
||||
bg: "#E8F5EF",
|
||||
color: "#0A6F4D",
|
||||
border: "#B7E6D6",
|
||||
label: "Rebooked",
|
||||
hint: "These wagons ride again on the rebooked shipment.",
|
||||
},
|
||||
};
|
||||
|
||||
/** Cancelled wagons + their cargo, categorized by cancellation state. */
|
||||
function CancelledWagonsSection({ rows }: { rows: WagonCancellation[] }) {
|
||||
const visible = rows.filter((r) => CANCEL_TONES[r.status]);
|
||||
if (!visible.length) return null;
|
||||
return (
|
||||
<SectionCard>
|
||||
<CardTitle>Cancelled wagons</CardTitle>
|
||||
<Stack gap="sm" mt="sm">
|
||||
{visible.map((r) => {
|
||||
const tone = CANCEL_TONES[r.status];
|
||||
const units = r.cancelledQuantities.units ?? [];
|
||||
return (
|
||||
<Box
|
||||
key={r.id}
|
||||
p={12}
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
backgroundColor: tone.bg,
|
||||
border: `1px solid ${tone.border}`,
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="center" wrap="wrap" gap={6}>
|
||||
<Text fz={13.5} fw={800} style={{ color: tone.color }}>
|
||||
{Number(r.wagonsCancelled)} wagon(s) — {tone.label}
|
||||
</Text>
|
||||
<Text fz={12} c="#6B7C8E">
|
||||
{fmtDate(r.createdAt)}
|
||||
{r.rebookedBooking
|
||||
? ` · new booking ${r.rebookedBooking.reference}`
|
||||
: ""}
|
||||
</Text>
|
||||
</Group>
|
||||
{units.length > 0 && (
|
||||
<Group gap={6} mt={6} wrap="wrap">
|
||||
{units.map((u) => (
|
||||
<Text
|
||||
key={u.containerNumber}
|
||||
component="span"
|
||||
fz={11.5}
|
||||
fw={700}
|
||||
px={8}
|
||||
py={2}
|
||||
style={{
|
||||
borderRadius: 6,
|
||||
backgroundColor: "white",
|
||||
border: `1px solid ${tone.border}`,
|
||||
color: tone.color,
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
>
|
||||
{u.containerNumber} · {u.containerSize}ft
|
||||
</Text>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
{!units.length && r.cancelledQuantities.bulkTons != null && (
|
||||
<Text fz={12.5} mt={4} style={{ color: tone.color }}>
|
||||
{Number(r.cancelledQuantities.bulkTons).toLocaleString()} tons of
|
||||
bulk cargo
|
||||
</Text>
|
||||
)}
|
||||
<Text fz={12} c="#6B7C8E" mt={6}>
|
||||
{tone.hint}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
const apiErrorMessage = (error: unknown, fallback: string) => {
|
||||
const data = (
|
||||
error as { response?: { data?: { message?: string | string[] } } }
|
||||
)?.response?.data;
|
||||
if (Array.isArray(data?.message)) return data.message.join(", ");
|
||||
if (data?.message) return data.message;
|
||||
return fallback;
|
||||
};
|
||||
|
||||
// Mirrors CargoTab's local Flag/StatTile look so the two tabs read as one page.
|
||||
const STATUS_TONES: Record<
|
||||
BookingWagonAllocation["status"],
|
||||
{ bg: string; color: string; label: string }
|
||||
> = {
|
||||
PLANNED: { bg: "#F1F4F7", color: "#475569", label: "Planned" },
|
||||
RESERVED: { bg: "#FFFBEB", color: "#92400E", label: "Reserved" },
|
||||
LOADED: { bg: "#E8F5EF", color: "#0A6F4D", label: "Loaded" },
|
||||
DEPARTED: { bg: "#EAF1FE", color: "#1E40AF", label: "Departed" },
|
||||
};
|
||||
|
||||
function StatusPill({ status }: { status: BookingWagonAllocation["status"] }) {
|
||||
const tone = STATUS_TONES[status] ?? STATUS_TONES.PLANNED;
|
||||
return (
|
||||
<Text
|
||||
component="span"
|
||||
fz={11}
|
||||
fw={700}
|
||||
px={9}
|
||||
py={3}
|
||||
style={{ borderRadius: 999, backgroundColor: tone.bg, color: tone.color }}
|
||||
>
|
||||
{tone.label}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function StatTile({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
sub?: string;
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
p={14}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
border: "1px solid #E6ECF2",
|
||||
backgroundColor: "#FAFCFE",
|
||||
}}
|
||||
>
|
||||
<Group gap={6} align="center" mb={6} c="#6B7C8E">
|
||||
{icon}
|
||||
<Text fz="11px" fw={700} tt="uppercase" style={{ letterSpacing: "0.05em" }}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fz={18} fw={800} c="#10202F" truncate>
|
||||
{value}
|
||||
</Text>
|
||||
{sub && (
|
||||
<Text fz={12} c="#9AA8B5" mt={2}>
|
||||
{sub}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Little consist strip: locomotive + one box per wagon, in marshalling order. */
|
||||
function ConsistStrip({ wagons }: { wagons: BookingWagonAllocation[] }) {
|
||||
return (
|
||||
<Box style={{ overflowX: "auto" }} pb={4}>
|
||||
<Group gap={5} wrap="nowrap" align="flex-end">
|
||||
<Box
|
||||
px={10}
|
||||
py={8}
|
||||
style={{
|
||||
borderRadius: "10px 4px 4px 10px",
|
||||
backgroundColor: "#10202F",
|
||||
color: "white",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<TrainFront size={16} />
|
||||
<Text fz={11} fw={800}>
|
||||
LOCO
|
||||
</Text>
|
||||
</Box>
|
||||
{wagons.map((w) => (
|
||||
<Tooltip
|
||||
key={w.sequenceNo}
|
||||
label={`${w.wagonNumber ?? "Unassigned"} · ${w.wagonType ?? "—"} · ${
|
||||
STATUS_TONES[w.status]?.label ?? w.status
|
||||
}`}
|
||||
withArrow
|
||||
>
|
||||
<Box
|
||||
px={10}
|
||||
py={8}
|
||||
ta="center"
|
||||
style={{
|
||||
borderRadius: 6,
|
||||
border: "1.5px solid #C9D6E2",
|
||||
backgroundColor: STATUS_TONES[w.status]?.bg ?? "#F1F4F7",
|
||||
flexShrink: 0,
|
||||
minWidth: 64,
|
||||
cursor: "default",
|
||||
}}
|
||||
>
|
||||
<Text fz={10} fw={700} c="#6B7C8E">
|
||||
W{w.sequenceNo}
|
||||
</Text>
|
||||
<Text fz={11.5} fw={800} c="#10202F" style={{ fontFamily: "monospace" }}>
|
||||
{w.wagonNumber ?? "—"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadBar({ allocated, capacity }: { allocated: number; capacity: number }) {
|
||||
const pct = capacity > 0 ? Math.min(100, Math.round((allocated / capacity) * 100)) : 0;
|
||||
return (
|
||||
<Box>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text fz={11.5} fw={700} c="#6B7C8E">
|
||||
Load
|
||||
</Text>
|
||||
<Text fz={11.5} fw={800} c="#10202F">
|
||||
{fmtWeight(allocated)}
|
||||
{capacity > 0 ? ` / ${fmtWeight(capacity)} · ${pct}%` : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
<Box style={{ height: 6, borderRadius: 999, backgroundColor: "#EDF2F7" }}>
|
||||
<Box
|
||||
style={{
|
||||
height: 6,
|
||||
width: `${pct}%`,
|
||||
borderRadius: 999,
|
||||
backgroundColor: pct >= 95 ? "#B45309" : "#0A6F4D",
|
||||
transition: "width 300ms ease",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const th = { color: "#9AA8B5", fontSize: 11 } as const;
|
||||
|
||||
function WagonCard({
|
||||
wagon,
|
||||
selectable,
|
||||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
wagon: BookingWagonAllocation;
|
||||
selectable?: boolean;
|
||||
selected?: boolean;
|
||||
onToggle?: () => void;
|
||||
}) {
|
||||
const allocated = Number(wagon.allocatedWeightTons || 0);
|
||||
const capacity = Number(wagon.capacityTons || 0);
|
||||
const containers = wagon.containers ?? [];
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
style={
|
||||
selected
|
||||
? { outline: "2px solid #B45309", outlineOffset: -2, borderRadius: 16 }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="sm">
|
||||
<Group gap={10} align="center" wrap="nowrap">
|
||||
{selectable && (
|
||||
<Checkbox
|
||||
checked={!!selected}
|
||||
onChange={onToggle}
|
||||
color="orange"
|
||||
aria-label={`Select wagon ${wagon.sequenceNo} for cancellation`}
|
||||
/>
|
||||
)}
|
||||
<Box
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 10,
|
||||
backgroundColor: "#10202F",
|
||||
color: "white",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Text fz={9} fw={700} c="#9AA8B5" lh={1}>
|
||||
WAGON
|
||||
</Text>
|
||||
<Text fz={15} fw={800} lh={1.2}>
|
||||
{wagon.sequenceNo}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz={16} fw={800} c="#10202F" style={{ fontFamily: "monospace" }}>
|
||||
{wagon.wagonNumber ?? "Not yet assigned"}
|
||||
</Text>
|
||||
<Text fz={12} c="#9AA8B5">
|
||||
{wagon.wagonType ?? "Wagon type pending"}
|
||||
{wagon.wagonTypeCode && wagon.wagonType !== wagon.wagonTypeCode
|
||||
? ` · ${wagon.wagonTypeCode}`
|
||||
: ""}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<StatusPill status={wagon.status} />
|
||||
</Group>
|
||||
|
||||
<LoadBar allocated={allocated} capacity={capacity} />
|
||||
|
||||
<Group gap={16} mt="sm" mb={containers.length || wagon.loadType === "BULK" ? "sm" : 0}>
|
||||
{Number(wagon.tareWeightTons) > 0 && (
|
||||
<Group gap={5}>
|
||||
<Scale size={12} color="#9AA8B5" />
|
||||
<Text fz={12} c="#475569">
|
||||
Tare {fmtWeight(Number(wagon.tareWeightTons))}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{Number(wagon.lengthMeters) > 0 && (
|
||||
<Group gap={5}>
|
||||
<Route size={12} color="#9AA8B5" />
|
||||
<Text fz={12} c="#475569">
|
||||
{Number(wagon.lengthMeters)} m
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
<Group gap={5}>
|
||||
{wagon.loadType === "BULK" ? (
|
||||
<Package size={12} color="#9AA8B5" />
|
||||
) : (
|
||||
<Container size={12} color="#9AA8B5" />
|
||||
)}
|
||||
<Text fz={12} c="#475569">
|
||||
{wagon.loadType === "BULK" ? "Bulk load" : "Container load"}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{wagon.loadType === "BULK" && (wagon.bulkCargoDescription || wagon.bulkQuantity) && (
|
||||
<Box
|
||||
p={10}
|
||||
style={{ borderRadius: 10, backgroundColor: "#FAFCFE", border: "1px solid #EDF2F7" }}
|
||||
>
|
||||
<Text fz={12.5} fw={700} c="#10202F">
|
||||
{wagon.bulkCargoDescription ?? "Bulk cargo"}
|
||||
</Text>
|
||||
{Number(wagon.bulkQuantity) > 0 && (
|
||||
<Text fz={12} c="#9AA8B5">
|
||||
Quantity: {Number(wagon.bulkQuantity).toLocaleString()}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{containers.length > 0 && (
|
||||
<Box style={{ overflowX: "auto" }}>
|
||||
<Table verticalSpacing={6} horizontalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th style={th}>Container no.</Table.Th>
|
||||
<Table.Th style={th}>Seal no.</Table.Th>
|
||||
<Table.Th style={th}>Gross wt.</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{containers.map((c, i) => (
|
||||
<Table.Tr key={c.containerNumber ?? i}>
|
||||
<Table.Td>
|
||||
<Text fz={12.5} fw={700} c="#10202F" style={{ fontFamily: "monospace" }}>
|
||||
{c.containerNumber ?? "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={12.5} c="#475569">
|
||||
{c.sealNumber ?? "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={12.5} c="#475569">
|
||||
{Number(c.grossWeightTons) > 0
|
||||
? fmtWeight(Number(c.grossWeightTons))
|
||||
: "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Wagons" tab: the customer's view of their allocated wagons once the paid
|
||||
* booking has been placed on a train — consist strip in marshalling order,
|
||||
* per-wagon load/containers, and the train's route summary.
|
||||
*/
|
||||
export function WagonsTab({
|
||||
bookingId,
|
||||
cancellable,
|
||||
onCancellationRequested,
|
||||
}: {
|
||||
bookingId: string;
|
||||
/** PAID contract booking — specific wagons may be selected for cancellation. */
|
||||
cancellable?: boolean;
|
||||
onCancellationRequested?: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: wagons, isLoading } = useQuery({
|
||||
queryKey: ["booking-wagons", bookingId],
|
||||
queryFn: () => bookingsService.getWagons(bookingId),
|
||||
enabled: !!bookingId,
|
||||
});
|
||||
|
||||
// Cancellation history: feeds the "Cancelled wagons" section and blocks a
|
||||
// second request while one is awaiting its fee.
|
||||
const { data: history } = useQuery({
|
||||
...api.bookings.listWagonCancellations.queryOptions({ input: { bookingId } }),
|
||||
enabled: !!bookingId,
|
||||
});
|
||||
const ownCancellations = (history?.items ?? []).filter(
|
||||
(r) => r.bookingId === bookingId,
|
||||
);
|
||||
const hasOpenCancellation = ownCancellations.some(
|
||||
(r) => r.status === "FEE_PENDING",
|
||||
);
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const [preview, setPreview] = useState<WagonCancellationPreview | null>(null);
|
||||
|
||||
const toggle = (allocationId: string) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(allocationId)) next.delete(allocationId);
|
||||
else next.add(allocationId);
|
||||
return next;
|
||||
});
|
||||
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
bookingsService.previewWagonCancellation(bookingId, {
|
||||
wagonAllocationIds: [...selected],
|
||||
}),
|
||||
onSuccess: setPreview,
|
||||
onError: (e) => {
|
||||
setPreview(null);
|
||||
toast.error(apiErrorMessage(e, "Could not calculate the fee. Please try again."));
|
||||
},
|
||||
});
|
||||
|
||||
const requestMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
bookingsService.requestWagonCancellation(bookingId, {
|
||||
wagonAllocationIds: [...selected],
|
||||
...(reason.trim() ? { reason: reason.trim() } : {}),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setConfirmOpen(false);
|
||||
setSelected(new Set());
|
||||
setReason("");
|
||||
setPreview(null);
|
||||
toast.success(
|
||||
"Cancellation requested — pay the fee in the wagon cancellation card to release these wagons.",
|
||||
{ duration: 7000 },
|
||||
);
|
||||
void queryClient.invalidateQueries({ queryKey: ["booking-wagons", bookingId] });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: api.bookings.listWagonCancellations.queryKey({ bookingId }),
|
||||
});
|
||||
onCancellationRequested?.();
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(apiErrorMessage(e, "Could not request the cancellation. Please try again.")),
|
||||
});
|
||||
|
||||
const openConfirm = () => {
|
||||
setPreview(null);
|
||||
setConfirmOpen(true);
|
||||
previewMutation.mutate();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6" style={{ maxWidth: 980 }}>
|
||||
<Skeleton height={140} radius={16} />
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing={24}>
|
||||
<Skeleton height={220} radius={16} />
|
||||
<Skeleton height={220} radius={16} />
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!wagons?.length) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6" style={{ maxWidth: 980 }}>
|
||||
<CancelledWagonsSection rows={ownCancellations} />
|
||||
<SectionCard>
|
||||
<Group gap={12} align="center">
|
||||
<Box
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 12,
|
||||
backgroundColor: "#F1F4F7",
|
||||
color: "#6B7C8E",
|
||||
}}
|
||||
>
|
||||
<TrainTrack size={22} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz={15} fw={800} c="#10202F">
|
||||
No wagons allocated yet
|
||||
</Text>
|
||||
<Text fz={13} c="#9AA8B5">
|
||||
Your wagons will appear here once the shipment is placed on a
|
||||
train after payment.
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</SectionCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const canSelect = !!cancellable && !hasOpenCancellation;
|
||||
const first = wagons[0];
|
||||
const totalAllocated = wagons.reduce(
|
||||
(s, w) => s + Number(w.allocatedWeightTons || 0),
|
||||
0,
|
||||
);
|
||||
const totalCapacity = wagons.reduce((s, w) => s + Number(w.capacityTons || 0), 0);
|
||||
const containerCount = wagons.reduce((s, w) => s + (w.containers?.length ?? 0), 0);
|
||||
const utilization =
|
||||
totalCapacity > 0 ? Math.round((totalAllocated / totalCapacity) * 100) : null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6" style={{ maxWidth: 980 }}>
|
||||
<SectionCard>
|
||||
<Group justify="space-between" align="flex-start" mb="md" wrap="wrap">
|
||||
<Group gap={10} align="center">
|
||||
<Box
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 10,
|
||||
backgroundColor: "#E8F5EF",
|
||||
color: "#0A6F4D",
|
||||
}}
|
||||
>
|
||||
<TrainFront size={18} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz={15} fw={800} c="#10202F">
|
||||
{first.trainNumber ? `Train ${first.trainNumber}` : "Your train"}
|
||||
</Text>
|
||||
<Group gap={5} align="center">
|
||||
<MapPin size={11} color="#9AA8B5" />
|
||||
<Text fz={12} c="#9AA8B5">
|
||||
{first.originStation ?? "—"} → {first.destinationStation ?? "—"}
|
||||
{first.departureAt ? ` · departs ${fmtDate(first.departureAt)}` : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
<CardTitle>Your wagons on this train</CardTitle>
|
||||
</Group>
|
||||
|
||||
<ConsistStrip wagons={wagons} />
|
||||
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing={10} mt="md">
|
||||
<StatTile
|
||||
icon={<TrainTrack size={13} />}
|
||||
label="Wagons"
|
||||
value={`${wagons.length}`}
|
||||
sub="allocated to you"
|
||||
/>
|
||||
<StatTile
|
||||
icon={<Scale size={13} />}
|
||||
label="Allocated weight"
|
||||
value={fmtWeight(totalAllocated)}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<Container size={13} />}
|
||||
label="Containers"
|
||||
value={containerCount ? `${containerCount}` : "—"}
|
||||
sub={containerCount ? "loaded on wagons" : undefined}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<Gauge size={13} />}
|
||||
label="Utilization"
|
||||
value={utilization != null ? `${utilization}%` : "—"}
|
||||
sub="of wagon capacity"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</SectionCard>
|
||||
|
||||
{canSelect && (
|
||||
<SectionCard>
|
||||
<Group justify="space-between" align="center" wrap="wrap" gap="sm">
|
||||
<Box>
|
||||
<Text fz={14} fw={800} c="#10202F">
|
||||
Cancel specific wagons
|
||||
</Text>
|
||||
<Text fz={12.5} c="#9AA8B5">
|
||||
Tick the wagons you want to cancel. They leave this train
|
||||
immediately; after you pay the per-wagon cancellation fee, the
|
||||
freight you paid for them becomes a credit you can rebook on
|
||||
another day.
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
color="orange"
|
||||
disabled={selected.size === 0 || selected.size >= wagons.length}
|
||||
onClick={openConfirm}
|
||||
>
|
||||
Cancel selected ({selected.size})
|
||||
</Button>
|
||||
</Group>
|
||||
{selected.size >= wagons.length && selected.size > 0 && (
|
||||
<Text fz={12} c="#B3362C" mt={6}>
|
||||
You cannot cancel every wagon here — to cancel the whole booking,
|
||||
use the booking cancellation instead.
|
||||
</Text>
|
||||
)}
|
||||
</SectionCard>
|
||||
)}
|
||||
{cancellable && hasOpenCancellation && (
|
||||
<Alert color="yellow" variant="light">
|
||||
A wagon cancellation is already awaiting its fee — pay or withdraw it
|
||||
in the wagon cancellation card before requesting another.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<CancelledWagonsSection rows={ownCancellations} />
|
||||
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing={24}>
|
||||
{wagons.map((w) => (
|
||||
<WagonCard
|
||||
key={w.allocationId ?? w.sequenceNo}
|
||||
wagon={w}
|
||||
selectable={
|
||||
canSelect &&
|
||||
!!w.allocationId &&
|
||||
(w.status === "PLANNED" || w.status === "RESERVED")
|
||||
}
|
||||
selected={!!w.allocationId && selected.has(w.allocationId)}
|
||||
onToggle={() => w.allocationId && toggle(w.allocationId)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<Modal
|
||||
opened={confirmOpen}
|
||||
onClose={() => setConfirmOpen(false)}
|
||||
title="Cancel selected wagons"
|
||||
centered
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text fz={13.5} c="#475569">
|
||||
You are cancelling <b>{selected.size}</b> wagon(s). They stay
|
||||
allocated to you until the cancellation fee is paid; after that the
|
||||
paid freight for them becomes a credit you can rebook on another
|
||||
day while your contract is valid.
|
||||
</Text>
|
||||
{previewMutation.isPending && <Skeleton height={64} radius={10} />}
|
||||
{preview && (
|
||||
<Box
|
||||
p={12}
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
backgroundColor: "#FFFBEB",
|
||||
border: "1px solid #FDE68A",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Text fz={13} c="#92400E">
|
||||
Cancellation fee ({preview.wagons} × {Number(preview.feePerWagon).toLocaleString()})
|
||||
</Text>
|
||||
<Text fz={14} fw={800} c="#92400E">
|
||||
{Number(preview.feeAmount).toLocaleString()} {preview.feeCurrency}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group justify="space-between" mt={4}>
|
||||
<Text fz={13} c="#0A6F4D">
|
||||
Rebooking credit kept
|
||||
</Text>
|
||||
<Text fz={14} fw={800} c="#0A6F4D">
|
||||
{Number(preview.creditAmount).toLocaleString()}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
<Textarea
|
||||
label="Reason (optional)"
|
||||
placeholder="Why are you cancelling these wagons?"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={() => setConfirmOpen(false)}>
|
||||
Keep wagons
|
||||
</Button>
|
||||
<Button
|
||||
color="orange"
|
||||
loading={requestMutation.isPending}
|
||||
disabled={!preview}
|
||||
onClick={() => requestMutation.mutate()}
|
||||
>
|
||||
Request cancellation
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -85,7 +85,12 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
|
||||
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
{isReady ? (
|
||||
{status === "OPERATION_CHANGES_REQUESTED" ? (
|
||||
<Alert color="yellow" radius="md" icon={<Clock size={18} />} mb="md">
|
||||
Operations returned this order for changes. Review their note, pick a
|
||||
new shipment day below and resubmit.
|
||||
</Alert>
|
||||
) : isReady ? (
|
||||
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mb="md">
|
||||
{needsCompletion
|
||||
? "Clearance is finalized. Complete your booking now — enter the cargo details and pick a shipment day inside an open booking window."
|
||||
|
||||
@@ -89,6 +89,22 @@ function actionByStatus(booking: ActionBooking): BookingNextAction | null {
|
||||
label: "Schedule & proceed",
|
||||
title: "Schedule your shipment",
|
||||
};
|
||||
case "OPERATION_CHANGES_REQUESTED":
|
||||
// Contract bookings reopen the full completion form (cargo + shipment
|
||||
// day, prefilled from the booking) — same page as the initial booking.
|
||||
// Contract-less bookings keep the in-place day-picker modal.
|
||||
return booking.contractId
|
||||
? {
|
||||
kind: "BOOK",
|
||||
label: "Change booking",
|
||||
title: "Change your booking",
|
||||
to: `/contracts/${booking.contractId}/bookings/${booking.id}/complete`,
|
||||
}
|
||||
: {
|
||||
kind: "SCHEDULE_OPERATION",
|
||||
label: "Choose day & resubmit",
|
||||
title: "Resubmit your shipment",
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -98,7 +98,10 @@ export function useClearanceFlow(booking: Freight.IBooking) {
|
||||
[clearance],
|
||||
);
|
||||
|
||||
const isReady = status === "CLEARANCE_READY";
|
||||
// OPERATION_CHANGES_REQUESTED re-opens the same pick-a-day flow: the
|
||||
// customer resubmits via the same clearance/proceed endpoint.
|
||||
const isReady =
|
||||
status === "CLEARANCE_READY" || status === "OPERATION_CHANGES_REQUESTED";
|
||||
// Bare initiated instance: created with no cargo and no price; completion
|
||||
// (cargo + shipment day + window check) happens on the full booking form.
|
||||
const isBareInstance =
|
||||
|
||||
@@ -3,18 +3,22 @@ import { useState } from "react";
|
||||
|
||||
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
|
||||
import { type PaymentMethod } from "@/services/payments.service";
|
||||
import { invoicesService } from "@/services/invoices.service";
|
||||
import { invoicesService, type PortalInvoice } from "@/services/invoices.service";
|
||||
import { isPayable } from "@/pages/billing/invoice-ui";
|
||||
|
||||
/** Fee invoice opened by a partial wagon cancellation (see WagonCancellationCard). */
|
||||
export const WAGON_CANCEL_FEE_INVOICE_TYPE = "WAGON_CANCEL_FEE";
|
||||
|
||||
/**
|
||||
* Shared payment flow for a single booking: opens the method modal, fires
|
||||
* POST /billing/my-invoices/:id/pay for the booking's currently payable
|
||||
* invoice, and redirects the browser to the provider (or, for CAC Bank, an
|
||||
* OTP debit with no redirect, collects the SMS'd code in the modal). Reused by
|
||||
* the booking detail page, the booking list, and the home page so "Pay now"
|
||||
* behaves identically everywhere.
|
||||
* Core of the booking payment flows: resolves the booking's invoices (shared
|
||||
* query/key with BookingPaymentPanel, so they share that cache), picks the one
|
||||
* matching `match`, and charges it through the ownership-checked portal route —
|
||||
* redirect vs CAC Bank OTP handled by `useInvoicePayment`.
|
||||
*/
|
||||
export function useBookingPayment(bookingId: string) {
|
||||
function useBookingInvoicePayment(
|
||||
bookingId: string,
|
||||
match: (invoice: PortalInvoice) => boolean,
|
||||
) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [noInvoice, setNoInvoice] = useState(false);
|
||||
|
||||
@@ -22,7 +26,7 @@ export function useBookingPayment(bookingId: string) {
|
||||
queryKey: ["booking-invoices", bookingId],
|
||||
queryFn: () => invoicesService.listForSource("booking", bookingId),
|
||||
});
|
||||
const payableInvoiceId = invoices.find((inv) => isPayable(inv.status))?.id;
|
||||
const payableInvoiceId = invoices.find(match)?.id;
|
||||
|
||||
const flow = useInvoicePayment();
|
||||
|
||||
@@ -56,3 +60,28 @@ export function useBookingPayment(bookingId: string) {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared payment flow for a single booking: opens the method modal, fires
|
||||
* POST /billing/my-invoices/:id/pay for the booking's currently payable
|
||||
* invoice, and redirects the browser to the provider (or, for CAC Bank, an
|
||||
* OTP debit with no redirect, collects the SMS'd code in the modal). Reused by
|
||||
* the booking detail page, the booking list, and the home page so "Pay now"
|
||||
* behaves identically everywhere.
|
||||
*/
|
||||
export function useBookingPayment(bookingId: string) {
|
||||
return useBookingInvoicePayment(bookingId, (inv) => isPayable(inv.status));
|
||||
}
|
||||
|
||||
/**
|
||||
* Same flow, but targets the booking's payable wagon-cancellation FEE invoice
|
||||
* (type WAGON_CANCEL_FEE) — the freight invoice is already paid on these
|
||||
* bookings, so the generic "first payable" pick would work today, but pinning
|
||||
* the type keeps the two buttons from ever racing over the same invoice.
|
||||
*/
|
||||
export function useFeeInvoicePayment(bookingId: string) {
|
||||
return useBookingInvoicePayment(
|
||||
bookingId,
|
||||
(inv) => inv.type === WAGON_CANCEL_FEE_INVOICE_TYPE && isPayable(inv.status),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -104,16 +104,29 @@ const COUNTDOWN_TEXT: Partial<
|
||||
PRE_WINDOW: { label: "Booking opens in", expiredText: "Booking opening now…" },
|
||||
OPEN: { label: "Window closes in", expiredText: "Document review starting…" },
|
||||
DOC_REVIEW: { label: "Document review ends in", expiredText: "Payment starting…" },
|
||||
PAYMENT: { label: "Payment due in", expiredText: "Payment window closing…" },
|
||||
PAYMENT: { label: "Payment due in", expiredText: "Finalizing payments…" },
|
||||
};
|
||||
|
||||
function phaseCountdown(
|
||||
w: MyBookingWindow,
|
||||
): { label: string; deadline: string; expiredText: string } | null {
|
||||
function phaseCountdown(w: MyBookingWindow): {
|
||||
label: string;
|
||||
deadline: string;
|
||||
expiredText: string;
|
||||
graceDeadline?: string | null;
|
||||
graceLabel?: string;
|
||||
} | null {
|
||||
const state = bookingWindowUiState(w);
|
||||
const text = COUNTDOWN_TEXT[state.kind];
|
||||
if (!state.countdownTo || !text) return null;
|
||||
return { ...text, deadline: state.countdownTo };
|
||||
// Once the pay deadline lapses, pending payments still settle during the
|
||||
// drain tail — count it down as "processing" instead of a stale "closing".
|
||||
const grace =
|
||||
state.kind === "PAYMENT" && w.paymentDrainEndsAt
|
||||
? {
|
||||
graceDeadline: w.paymentDrainEndsAt,
|
||||
graceLabel: "Payment processing — closes in",
|
||||
}
|
||||
: undefined;
|
||||
return { ...text, deadline: state.countdownTo, ...grace };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -226,6 +239,8 @@ function WindowCard({ w }: { w: MyBookingWindow }) {
|
||||
deadline={cd.deadline}
|
||||
label={cd.label}
|
||||
expiredText={cd.expiredText}
|
||||
graceDeadline={cd.graceDeadline}
|
||||
graceLabel={cd.graceLabel}
|
||||
size="xs"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
FileDown,
|
||||
FileText,
|
||||
FileUp,
|
||||
Flame,
|
||||
MapPin,
|
||||
@@ -59,6 +60,7 @@ import {
|
||||
contractsService,
|
||||
type ShipmentValidation,
|
||||
} from "@/services/contracts.service";
|
||||
import { downloadStoredFile } from "@/services/files.service";
|
||||
import {
|
||||
SelectField,
|
||||
StepCard,
|
||||
@@ -245,6 +247,127 @@ function bulkUnitOfMeasure(
|
||||
return hasPerItem ? "PER_ITEM" : "PER_TON";
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefill for a changes-requested resubmit: the booking's persisted cargo,
|
||||
* currency and route become the form's starting values so the customer edits
|
||||
* what exists instead of retyping it. The shipment day is deliberately left
|
||||
* empty — a new day must be picked.
|
||||
*/
|
||||
function mapBookingToShipmentValues(
|
||||
booking: Freight.IBooking,
|
||||
contract: Freight.IContract,
|
||||
): Partial<ShipmentFormInputValues> {
|
||||
const b = booking as unknown as {
|
||||
cargoFreeText?: string | null;
|
||||
contractRouteId?: string | null;
|
||||
cargoTotalWeightVgm?: number | string | null;
|
||||
bulkTotalWeightTons?: number | string | null;
|
||||
bulkHazardousQuantity?: number | string | null;
|
||||
bulkReeferQuantity?: number | string | null;
|
||||
bookingContainers?: Array<{
|
||||
quantity?: number;
|
||||
hazardousQuantity?: number | string | null;
|
||||
reeferQuantity?: number | string | null;
|
||||
returnQuantity?: number | string | null;
|
||||
containerType?: { sizeFt?: number | null } | null;
|
||||
units?: Array<{
|
||||
containerNumber?: string;
|
||||
sealNumber?: string | null;
|
||||
vgmTons?: number | string;
|
||||
isHazardous?: boolean;
|
||||
isReefer?: boolean;
|
||||
isReturn?: boolean;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
const values: Partial<ShipmentFormInputValues> = {
|
||||
paymentCurrency: booking.paymentCurrency === "ETB" ? "ETB" : "USD",
|
||||
withReturn: booking.equipmentReturn === "WITH_RETURN",
|
||||
cargoDescription: b.cargoFreeText ?? "",
|
||||
...(b.contractRouteId ? { contractRouteId: b.contractRouteId } : {}),
|
||||
};
|
||||
if (contract.freightType === "CONTAINER") {
|
||||
const rows = b.bookingContainers ?? [];
|
||||
const lineFor = (size: "20ft" | "40ft") => {
|
||||
const bc = rows.find(
|
||||
(r) => (r.containerType?.sizeFt === 40 ? "40ft" : "20ft") === size,
|
||||
);
|
||||
return {
|
||||
containerSize: size,
|
||||
quantity: String(bc?.quantity ?? 0),
|
||||
hazardousQuantity: String(Number(bc?.hazardousQuantity ?? 0)),
|
||||
reeferQuantity: String(Number(bc?.reeferQuantity ?? 0)),
|
||||
returnQuantity: String(Number(bc?.returnQuantity ?? 0)),
|
||||
units: (bc?.units ?? []).map((u) => ({
|
||||
containerNumber: u.containerNumber ?? "",
|
||||
sealNumber: u.sealNumber ?? "",
|
||||
vgmTons: String(Number(u.vgmTons ?? 0)),
|
||||
isHazardous: Boolean(u.isHazardous),
|
||||
isReefer: Boolean(u.isReefer),
|
||||
isReturn: Boolean(u.isReturn),
|
||||
})),
|
||||
};
|
||||
};
|
||||
const sizes = (contract.cargoScope ?? [])
|
||||
.map((s) => s.containerSize)
|
||||
.filter((s): s is "20ft" | "40ft" => s === "20ft" || s === "40ft");
|
||||
values.containers = (sizes.length ? sizes : (["20ft", "40ft"] as const)).map(lineFor);
|
||||
} else {
|
||||
const perItem = bulkUnitOfMeasure(contract) === "PER_ITEM";
|
||||
const amount = Number(b.cargoTotalWeightVgm ?? 0);
|
||||
if (perItem) {
|
||||
values.itemCount = amount ? String(amount) : "";
|
||||
values.cargoWeightTons =
|
||||
b.bulkTotalWeightTons != null
|
||||
? String(Number(b.bulkTotalWeightTons))
|
||||
: "";
|
||||
} else {
|
||||
values.cargoWeightTons = amount ? String(amount) : "";
|
||||
}
|
||||
values.bulkHazardousQuantity = String(Number(b.bulkHazardousQuantity ?? 0));
|
||||
values.bulkReeferQuantity = String(Number(b.bulkReeferQuantity ?? 0));
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/** Read-only list of the booking's already-uploaded documents (resubmit view). */
|
||||
function UploadedDocumentsCard({ booking }: { booking: Freight.IBooking }) {
|
||||
const files =
|
||||
(booking as unknown as { files?: Array<{ id: string; name: string }> })
|
||||
.files ?? [];
|
||||
if (!files.length) return null;
|
||||
return (
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group gap={8} mb={4}>
|
||||
<FileText size={16} />
|
||||
<Text fw={700} fz="sm">
|
||||
Your uploaded documents
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fz={12.5} c="dimmed" mb="sm">
|
||||
These stay attached to the booking — no need to upload them again.
|
||||
</Text>
|
||||
<Stack gap={6}>
|
||||
{files.map((f) => (
|
||||
<Group key={f.id} justify="space-between" wrap="nowrap">
|
||||
<Text fz={13} truncate>
|
||||
{f.name}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
radius="md"
|
||||
onClick={() => void downloadStoredFile(f.id, f.name)}
|
||||
aria-label={`Download ${f.name}`}
|
||||
>
|
||||
<FileDown size={15} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function NewShipmentBookingForm({
|
||||
contract,
|
||||
contractId,
|
||||
@@ -284,6 +407,10 @@ function NewShipmentBookingForm({
|
||||
unitOfMeasure: bulkUnitOfMeasure(contract),
|
||||
// Intercity rides a passing train staff pick later — no date to choose.
|
||||
requiresDate: contract.tradeDirection !== "DOMESTIC",
|
||||
// Export completion locks onto a specific train — the pick is required
|
||||
// (mirrors the ScheduleStep picker's visibility).
|
||||
requiresTrain:
|
||||
contract.tradeDirection === "EXPORT" && Boolean(completeBookingId),
|
||||
}),
|
||||
),
|
||||
mode: "onChange",
|
||||
@@ -302,6 +429,34 @@ function NewShipmentBookingForm({
|
||||
: 0;
|
||||
const hasOdd20ft = ft20Total % 2 === 1;
|
||||
|
||||
// COMPLETION mode: fetch the booking — a changes-requested resubmit prefills
|
||||
// the form from it and shows the operations note + uploaded documents.
|
||||
const { data: completeBooking } = useQuery(
|
||||
api.bookings.get.queryOptions({
|
||||
input: { id: completeBookingId! },
|
||||
enabled: Boolean(completeBookingId),
|
||||
}),
|
||||
);
|
||||
const isResubmit = Boolean(
|
||||
completeBooking &&
|
||||
["OPERATION_CHANGES_REQUESTED", "EXPIRED"].includes(
|
||||
completeBooking.status as string,
|
||||
) &&
|
||||
(((completeBooking as unknown as { bookingContainers?: unknown[] })
|
||||
.bookingContainers?.length ?? 0) > 0 ||
|
||||
Number(completeBooking.cargoTotalWeightVgm ?? 0) > 0),
|
||||
);
|
||||
const prefilledRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!isResubmit || prefilledRef.current || !completeBooking) return;
|
||||
prefilledRef.current = true;
|
||||
form.reset({
|
||||
...form.getValues(),
|
||||
...mapBookingToShipmentValues(completeBooking, contract),
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isResubmit]);
|
||||
|
||||
const submitMutation = useMutation({
|
||||
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
|
||||
completeBookingId
|
||||
@@ -328,7 +483,12 @@ function NewShipmentBookingForm({
|
||||
// price modal opens so re-reviewing after an edit re-checks.
|
||||
const validateMutation = useMutation({
|
||||
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
|
||||
api.contracts.validateShipment.call({ id: contractId, dto }),
|
||||
api.contracts.validateShipment.call({
|
||||
id: contractId,
|
||||
dto,
|
||||
// Resubmit preview must not clash with this booking's own containers.
|
||||
excludeBookingId: completeBookingId,
|
||||
}),
|
||||
});
|
||||
|
||||
function buildDto(
|
||||
@@ -484,12 +644,16 @@ function NewShipmentBookingForm({
|
||||
style={{ letterSpacing: "-0.01em" }}
|
||||
>
|
||||
{completeBookingId
|
||||
? "Complete Your Booking"
|
||||
? isResubmit
|
||||
? "Change Your Booking"
|
||||
: "Complete Your Booking"
|
||||
: "New Shipment Booking"}
|
||||
</Title>
|
||||
<Text size="sm" c="edr-muted" mt={4}>
|
||||
{completeBookingId
|
||||
? `Clearance is finalized — enter the cargo details and shipment day to complete your booking under contract ${contract.reference}.`
|
||||
? isResubmit
|
||||
? `Update the details below and pick a new shipment day, then resubmit your booking under contract ${contract.reference}.`
|
||||
: `Clearance is finalized — enter the cargo details and shipment day to complete your booking under contract ${contract.reference}.`
|
||||
: `Book a shipment against contract ${contract.reference}.`}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -531,6 +695,16 @@ function NewShipmentBookingForm({
|
||||
|
||||
{/* Single-step form — all sections on one page. */}
|
||||
<Stack gap="lg" className="mx-auto max-w-4xl">
|
||||
{isResubmit && completeBooking?.latestChangeRequestNote && (
|
||||
<Alert
|
||||
color="yellow"
|
||||
icon={<AlertCircle size={16} />}
|
||||
radius="md"
|
||||
title="Operations requested changes"
|
||||
>
|
||||
<Text size="sm">{completeBooking.latestChangeRequestNote}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
<RouteStep form={form} contract={contract} routes={routes} />
|
||||
<CargoStep form={form} contract={contract} />
|
||||
{/* Legacy contracts only — WITH_RETURN contracts capture per-line
|
||||
@@ -543,6 +717,9 @@ function NewShipmentBookingForm({
|
||||
routes={routes}
|
||||
completeBookingId={completeBookingId ?? null}
|
||||
/>
|
||||
{isResubmit && completeBooking && (
|
||||
<UploadedDocumentsCard booking={completeBooking} />
|
||||
)}
|
||||
{/* Notes are captured when the booking is initiated — completing
|
||||
a bare booking does not re-ask for them. */}
|
||||
{!completeBookingId && <NotesSection form={form} />}
|
||||
@@ -590,7 +767,7 @@ function NewShipmentBookingForm({
|
||||
onClick={handleReview}
|
||||
disabled={hasOdd20ft}
|
||||
>
|
||||
Review price & book
|
||||
{isResubmit ? "Change booking" : "Review price & book"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
@@ -1179,12 +1356,23 @@ function ScheduleStep({
|
||||
</Text>
|
||||
)}
|
||||
{isExportPick && scheduledDate ? (
|
||||
<ExportTrainPicker
|
||||
options={exportTrainsQuery.data ?? []}
|
||||
loading={exportTrainsQuery.isLoading}
|
||||
value={selectedTrainId ?? ""}
|
||||
onChange={(id) => form.setValue("trainScheduleId", id)}
|
||||
/>
|
||||
<>
|
||||
<ExportTrainPicker
|
||||
options={exportTrainsQuery.data ?? []}
|
||||
loading={exportTrainsQuery.isLoading}
|
||||
value={selectedTrainId ?? ""}
|
||||
onChange={(id) =>
|
||||
form.setValue("trainScheduleId", id, {
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{form.formState.errors.trainScheduleId?.message && (
|
||||
<Text fz="xs" c="red" mt={6}>
|
||||
{String(form.formState.errors.trainScheduleId.message)}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -30,6 +30,11 @@ export interface ShipmentValidationContext {
|
||||
* staff pick later, so no shipment day is chosen. Defaults to true.
|
||||
*/
|
||||
requiresDate?: boolean;
|
||||
/**
|
||||
* EXPORT rail completion: the shipment must ride a specific train the
|
||||
* customer picks for the chosen day. Defaults to false.
|
||||
*/
|
||||
requiresTrain?: boolean;
|
||||
}
|
||||
|
||||
// ISO 6346: 3-letter owner code + category id (U/J/Z) + 6-digit serial + check digit.
|
||||
@@ -102,6 +107,20 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
|
||||
});
|
||||
}
|
||||
|
||||
// Train is only pickable once a day is chosen — the day error covers the
|
||||
// no-date case, so don't stack a second error on an invisible field.
|
||||
if (
|
||||
ctx.requiresTrain &&
|
||||
data.scheduledDate.trim() &&
|
||||
!data.trainScheduleId.trim()
|
||||
) {
|
||||
refineCtx.addIssue({
|
||||
code: "custom",
|
||||
path: ["trainScheduleId"],
|
||||
message: "Select a train for your shipment day.",
|
||||
});
|
||||
}
|
||||
|
||||
// No default currency — the customer must pick one before submitting.
|
||||
if (!data.paymentCurrency) {
|
||||
refineCtx.addIssue({
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createShipmentFormSchema, initialShipmentFormValues } from "./schema";
|
||||
|
||||
const schema = createShipmentFormSchema({
|
||||
isContainer: false,
|
||||
isHazardous: false,
|
||||
isReefer: false,
|
||||
requiresTrain: true,
|
||||
});
|
||||
|
||||
const values = (over: Record<string, unknown> = {}) => ({
|
||||
...initialShipmentFormValues,
|
||||
cargoWeightTons: "10",
|
||||
paymentCurrency: "USD",
|
||||
scheduledDate: "2026-08-10",
|
||||
...over,
|
||||
});
|
||||
|
||||
const trainIssue = (input: Record<string, unknown>) => {
|
||||
const result = schema.safeParse(input);
|
||||
return result.success
|
||||
? undefined
|
||||
: result.error.issues.find((i) => i.path[0] === "trainScheduleId");
|
||||
};
|
||||
|
||||
describe("requiresTrain", () => {
|
||||
it("rejects a dated export completion without a train pick", () => {
|
||||
expect(trainIssue(values())?.message).toMatch(/select a train/i);
|
||||
});
|
||||
|
||||
it("passes once a train is picked", () => {
|
||||
expect(trainIssue(values({ trainScheduleId: "sched-1" }))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stays silent while no date is chosen (day error covers it)", () => {
|
||||
expect(trainIssue(values({ scheduledDate: "" }))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("is off by default (non-completion flows)", () => {
|
||||
const plain = createShipmentFormSchema({
|
||||
isContainer: false,
|
||||
isHazardous: false,
|
||||
isReefer: false,
|
||||
});
|
||||
const result = plain.safeParse(values());
|
||||
expect(
|
||||
result.success ||
|
||||
result.error.issues.every((i) => i.path[0] !== "trainScheduleId"),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
GeneratePriceResponse,
|
||||
type MyBookingWindow,
|
||||
SubmitBookingResponse,
|
||||
type WagonCancellationListFilter,
|
||||
type WagonCancellationListResponse,
|
||||
} from "./bookings.service";
|
||||
import {
|
||||
contractsService,
|
||||
@@ -483,6 +485,22 @@ export const api = {
|
||||
bookingsService.getExportTrains(bookingId, date, cargo),
|
||||
),
|
||||
|
||||
listWagonCancellations: endpoint<
|
||||
{ bookingId: string },
|
||||
WagonCancellationListResponse
|
||||
>("bookings", "listWagonCancellations", ({ bookingId }) =>
|
||||
bookingsService.listWagonCancellations(bookingId),
|
||||
),
|
||||
|
||||
listMyWagonCancellations: endpoint<
|
||||
WagonCancellationListFilter | void,
|
||||
WagonCancellationListResponse
|
||||
>(
|
||||
"bookings",
|
||||
"listMyWagonCancellations",
|
||||
bookingsService.listMyWagonCancellations,
|
||||
),
|
||||
|
||||
getMyBookingWindows: endpoint<void, MyBookingWindow[]>(
|
||||
"train-scheduling",
|
||||
"myBookingWindows",
|
||||
@@ -610,10 +628,14 @@ export const api = {
|
||||
),
|
||||
|
||||
validateShipment: endpoint<
|
||||
{ id: string; dto: Freight.CreateBookingUnderContractDto },
|
||||
{
|
||||
id: string;
|
||||
dto: Freight.CreateBookingUnderContractDto;
|
||||
excludeBookingId?: string;
|
||||
},
|
||||
ShipmentValidation
|
||||
>("contracts", "validateShipment", ({ id, dto }) =>
|
||||
contractsService.validateShipment(id, dto),
|
||||
>("contracts", "validateShipment", ({ id, dto, excludeBookingId }) =>
|
||||
contractsService.validateShipment(id, dto, excludeBookingId),
|
||||
),
|
||||
|
||||
getContractMilestones: endpoint<
|
||||
|
||||
@@ -91,6 +91,8 @@ export interface MyBookingWindow {
|
||||
windowClosesAt: string | null;
|
||||
docReviewEndsAt: string | null;
|
||||
paymentPhaseEndsAt: string | null;
|
||||
/** End of the payment drain tail — pending payments may settle until then. */
|
||||
paymentDrainEndsAt: string | null;
|
||||
bookingWindowStatus: string;
|
||||
bookingCycleNo: number;
|
||||
departureDate: string;
|
||||
@@ -193,6 +195,116 @@ export interface BookingListFilter {
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
}
|
||||
|
||||
export interface BookingWagonContainer {
|
||||
containerNumber: string | null;
|
||||
sealNumber: string | null;
|
||||
positionOnWagon: number | null;
|
||||
grossWeightTons: string | null;
|
||||
}
|
||||
|
||||
/** One allocated wagon of a booking, as returned by GET /bookings/:id/wagons. */
|
||||
export interface BookingWagonAllocation {
|
||||
/** wagon_booking_allocations id — the handle for cancelling this specific wagon. */
|
||||
allocationId: string;
|
||||
sequenceNo: number;
|
||||
wagonNumber: string | null;
|
||||
wagonType: string | null;
|
||||
wagonTypeCode: string | null;
|
||||
tareWeightTons: string | null;
|
||||
capacityTons: string | null;
|
||||
lengthMeters: string | null;
|
||||
allocatedWeightTons: string | null;
|
||||
loadType: "CONTAINER" | "BULK";
|
||||
status: "PLANNED" | "RESERVED" | "LOADED" | "DEPARTED";
|
||||
trainNumber: string | null;
|
||||
departureAt: string | null;
|
||||
originStation: string | null;
|
||||
destinationStation: string | null;
|
||||
bulkCargoDescription: string | null;
|
||||
bulkQuantity: string | null;
|
||||
containers: BookingWagonContainer[];
|
||||
}
|
||||
|
||||
// ── Partial wagon cancellation (paid bookings) ──────────────────────────────
|
||||
|
||||
export type WagonCancellationStatus =
|
||||
| "FEE_PENDING"
|
||||
| "CREDIT_AVAILABLE"
|
||||
| "REBOOKED"
|
||||
| "WITHDRAWN"
|
||||
| "EXPIRED";
|
||||
|
||||
/** One partial-cancellation ledger row of a paid booking. */
|
||||
export interface WagonCancellation {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
rebookedBookingId?: string | null;
|
||||
wagonsCancelled: number;
|
||||
weightTons: number;
|
||||
/** What was cut: bulk tons, or container units per size (ft). */
|
||||
cancelledQuantities: {
|
||||
bulkTons?: number;
|
||||
bySize?: Record<string, number>;
|
||||
/** Exact physical containers leaving with the cancelled wagons. */
|
||||
units?: Array<{
|
||||
containerSize: string;
|
||||
containerNumber: string;
|
||||
sealNumber?: string | null;
|
||||
vgmTons: number;
|
||||
isHazardous: boolean;
|
||||
isReefer: boolean;
|
||||
}>;
|
||||
/** Wagons already left the schedule when the request was made. */
|
||||
releasedAtRequest?: boolean;
|
||||
};
|
||||
/** Rebooking credit — the cancelled share of the original freight price. */
|
||||
creditAmount: number;
|
||||
feeAmount: number;
|
||||
feeCurrency: string;
|
||||
feeInvoiceId?: string | null;
|
||||
feePaidAt?: string | null;
|
||||
status: WagonCancellationStatus;
|
||||
reason?: string | null;
|
||||
rebookedAt?: string | null;
|
||||
createdAt: string;
|
||||
booking?: { id: string; reference: string } | null;
|
||||
rebookedBooking?: { id: string; reference: string } | null;
|
||||
}
|
||||
|
||||
/** Fee/credit preview of a partial wagon cancellation (no writes). */
|
||||
export interface WagonCancellationPreview {
|
||||
wagons: number;
|
||||
weightTons: number;
|
||||
feePerWagon: number;
|
||||
feeAmount: number;
|
||||
feeCurrency: string;
|
||||
creditAmount: number;
|
||||
}
|
||||
|
||||
export interface RequestWagonCancellationPayload {
|
||||
/** Cancel SPECIFIC wagons: allocationIds from getWagons. Overrides the fields below. */
|
||||
wagonAllocationIds?: string[];
|
||||
/** BULK bookings: number of wagons to cancel (tons derived proportionally). */
|
||||
wagons?: number;
|
||||
/** CONTAINER bookings: units to cancel per size ("20"/"40", as stored on the line). */
|
||||
containers?: Array<{ containerSize: string; quantity: number }>;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface WagonCancellationListFilter {
|
||||
statuses?: string[];
|
||||
search?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface WagonCancellationListResponse {
|
||||
items: WagonCancellation[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export const bookingsService = {
|
||||
list: async (
|
||||
filter: BookingListFilter | void = {},
|
||||
@@ -315,6 +427,16 @@ export const bookingsService = {
|
||||
return data.data;
|
||||
},
|
||||
|
||||
customerCancel: async (
|
||||
id: string,
|
||||
reason?: string,
|
||||
): Promise<Freight.IBooking> => {
|
||||
const { data } = await client.post(`/api/bookings/${id}/customer-cancel`, {
|
||||
reason,
|
||||
});
|
||||
return data.data;
|
||||
},
|
||||
|
||||
reject: async (id: string, reason?: string): Promise<Freight.IBooking> => {
|
||||
const { data } = await client.post(`/api/bookings/${id}/reject`, { reason });
|
||||
return data.data;
|
||||
@@ -548,6 +670,82 @@ export const bookingsService = {
|
||||
return data.data as Freight.DayAvailabilityResponse;
|
||||
},
|
||||
|
||||
/**
|
||||
* Allocated wagons for a paid booking (empty until placed on a train).
|
||||
* One row per wagon with its containers / bulk load.
|
||||
*/
|
||||
getWagons: async (bookingId: string): Promise<BookingWagonAllocation[]> => {
|
||||
const { data } = await client.get(`/api/bookings/${bookingId}/wagons`);
|
||||
return (data.data ?? data) as BookingWagonAllocation[];
|
||||
},
|
||||
|
||||
// ── Partial wagon cancellation ──
|
||||
/** Fee/credit preview for the confirm dialog — same math as the request, no writes. */
|
||||
previewWagonCancellation: async (
|
||||
id: string,
|
||||
payload: RequestWagonCancellationPayload,
|
||||
): Promise<WagonCancellationPreview> => {
|
||||
const { data } = await client.post(
|
||||
`/api/bookings/${id}/wagon-cancellations/preview`,
|
||||
payload,
|
||||
);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** Open a cancellation: issues the fee invoice; wagons release once the fee settles. */
|
||||
requestWagonCancellation: async (
|
||||
id: string,
|
||||
payload: RequestWagonCancellationPayload,
|
||||
): Promise<WagonCancellation> => {
|
||||
const { data } = await client.post(
|
||||
`/api/bookings/${id}/wagon-cancellations`,
|
||||
payload,
|
||||
);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** Cancellation history of one booking (as source and as rebooked target). */
|
||||
listWagonCancellations: async (
|
||||
bookingId: string,
|
||||
): Promise<WagonCancellationListResponse> => {
|
||||
const { data } = await client.get(
|
||||
`/api/bookings/${bookingId}/wagon-cancellations`,
|
||||
);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** The signed-in customer's wagon cancellations (paginated, filterable). */
|
||||
listMyWagonCancellations: async (
|
||||
filter: WagonCancellationListFilter | void = {},
|
||||
): Promise<WagonCancellationListResponse> => {
|
||||
const { data } = await client.get("/api/bookings/wagon-cancellations/my", {
|
||||
params: filter,
|
||||
});
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** Void a FEE_PENDING request — the fee invoice is cancelled, nothing was released. */
|
||||
withdrawWagonCancellation: async (
|
||||
cancellationId: string,
|
||||
): Promise<WagonCancellation> => {
|
||||
const { data } = await client.post(
|
||||
`/api/bookings/wagon-cancellations/${cancellationId}/withdraw`,
|
||||
);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** Rebook a CREDIT_AVAILABLE cancellation onto a shipment day → new PAID booking. */
|
||||
rebookWagonCancellation: async (
|
||||
cancellationId: string,
|
||||
payload: { scheduledDate: string },
|
||||
): Promise<{ cancellation: WagonCancellation; bookingId: string }> => {
|
||||
const { data } = await client.post(
|
||||
`/api/bookings/wagon-cancellations/${cancellationId}/rebook`,
|
||||
payload,
|
||||
);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Upcoming/open booking windows on the signed-in customer's active-contract
|
||||
* lanes (import booking-day windows + export 24h pre-departure windows).
|
||||
|
||||
@@ -400,8 +400,13 @@ export const contractsService = {
|
||||
validateShipment: async (
|
||||
id: string,
|
||||
dto: Freight.CreateBookingUnderContractDto,
|
||||
// Completion/resubmit: exclude this booking's own containers from the
|
||||
// same-train clash check.
|
||||
excludeBookingId?: string,
|
||||
): Promise<ShipmentValidation> => {
|
||||
const { data } = await client.post(C.VALIDATE_SHIPMENT(id), dto);
|
||||
const { data } = await client.post(C.VALIDATE_SHIPMENT(id), dto, {
|
||||
params: excludeBookingId ? { bookingId: excludeBookingId } : undefined,
|
||||
});
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -86,5 +86,5 @@ export default {
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -48,35 +48,23 @@ export function defaultPaymentReason(
|
||||
: "Freight invoice";
|
||||
}
|
||||
|
||||
/**
|
||||
* CBE reads Response_Description back to the payer at the counter or in the USSD prompt, so it
|
||||
* has to name the thing they are actually holding — a passenger booking or a freight invoice —
|
||||
* rather than our internal "bill" abstraction (plan §6.6).
|
||||
*/
|
||||
function subjectOf(referenceType: PaymentReferenceType): string {
|
||||
return referenceType === PaymentReferenceType.BOOKING ? "booking" : "invoice";
|
||||
}
|
||||
|
||||
export function reasonToDescription(
|
||||
reason: string | null | undefined,
|
||||
referenceType: PaymentReferenceType,
|
||||
): string {
|
||||
const subject = subjectOf(referenceType);
|
||||
/** Short descriptions per CBE integration request — CBE's channel renders them as-is. */
|
||||
export function reasonToDescription(reason: string | null | undefined): string {
|
||||
switch (reason) {
|
||||
case "ALREADY_PAID":
|
||||
return `This ${subject} has already been paid.`;
|
||||
return "Already paid";
|
||||
case "CANCELLED":
|
||||
return `This ${subject} has been cancelled.`;
|
||||
return "Cancelled";
|
||||
case "REFUNDED":
|
||||
return `This ${subject} has been refunded.`;
|
||||
return "Refunded";
|
||||
case "EXPIRED":
|
||||
return `This ${subject} has expired and can no longer be paid.`;
|
||||
return "Expired";
|
||||
// A bill reference we issued whose order has since vanished from the domain app. Same
|
||||
// wording as an unknown Bill_Id — from the teller's side it is the same situation.
|
||||
case "NOT_FOUND":
|
||||
return "Bill not found.";
|
||||
return "Bill not found";
|
||||
default:
|
||||
return `This ${subject} is no longer payable.`;
|
||||
return "Not payable";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +123,7 @@ export class BillResolverService {
|
||||
}`,
|
||||
);
|
||||
// TRANSIENT so CBE may retry the same End_To_End_Txn_Id once we recover (plan R5).
|
||||
throw new CbeBillError("Service temporarily unavailable.", "TRANSIENT");
|
||||
throw new CbeBillError("Service unavailable", "TRANSIENT");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import { CbePaymentResponseDto } from "./dto/cbe-payment-response.dto";
|
||||
* CBE Unified Bill Payment — the INBOUND surface CBE core banking calls (docs/cbe/). We are
|
||||
* the biller: CBE authenticates against /cbe/oauth/token with credentials we issued, then
|
||||
* presents the bearer token on /cbe/query and /cbe/payment. Business failures answer HTTP 200
|
||||
* with Response_Code "3"; only authentication answers 401 (plan D6/D7).
|
||||
* with Response_Code "2"; only authentication answers 401 (plan D6/D7).
|
||||
*/
|
||||
@ApiTags("CBE Unified Bill (inbound)")
|
||||
@Controller("cbe")
|
||||
|
||||
@@ -59,7 +59,7 @@ function localReason(intent: PaymentIntent): BillNotPayableReason {
|
||||
|
||||
/**
|
||||
* Orchestration for CBE's three inbound calls (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 3).
|
||||
* Business failures return HTTP 200 + Response_Code "3" envelopes (never throw past the
|
||||
* Business failures return HTTP 200 + Response_Code "2" envelopes (never throw past the
|
||||
* controller); the exception filter only catches auth, validation, and the unexpected.
|
||||
*/
|
||||
@Injectable()
|
||||
@@ -130,7 +130,7 @@ export class CbeBillService {
|
||||
const billQuery = await this.billResolver.billQuery(intent);
|
||||
if (!billQuery.stillPayable) {
|
||||
throw new CbeBillError(
|
||||
reasonToDescription(billQuery.reason, intent.referenceType),
|
||||
reasonToDescription(billQuery.reason),
|
||||
"BUSINESS",
|
||||
);
|
||||
}
|
||||
@@ -184,11 +184,15 @@ export class CbeBillService {
|
||||
);
|
||||
if (prior) {
|
||||
if (prior.tradeStatus === "SUCCESS") {
|
||||
// Replay the stored body verbatim. Never re-settle.
|
||||
return prior.responsePayload as unknown as CbePaymentResponseDto;
|
||||
// Per CBE integration request: a settled End_To_End_Txn_Id never replays the stored
|
||||
// success — every repeat answers "Already paid". Money moved exactly once (the first
|
||||
// call); this only changes what a duplicate hears back. NOTE this diverges from the
|
||||
// original §6.5 replay design: if CBE retries because our SUCCESS response was lost
|
||||
// in transit, it now sees FAILED for a debit we kept — reconcile such cases manually.
|
||||
return mapPaymentFailure(dto, "Already paid");
|
||||
}
|
||||
if (prior.tradeStatus === "PENDING") {
|
||||
return mapPaymentFailure(dto, "Payment is being processed.");
|
||||
return mapPaymentFailure(dto, "Payment in progress");
|
||||
}
|
||||
if (prior.failureClass === "BUSINESS") {
|
||||
// Final — retrying cannot change the answer. Same End_To_End_Txn_Id was already
|
||||
@@ -196,7 +200,7 @@ export class CbeBillService {
|
||||
// echoing the original reason, which no longer describes this request.
|
||||
return mapPaymentFailure(
|
||||
dto,
|
||||
`End_To_End_Txn_Id ${dto.End_To_End_Txn_Id} was already processed and failed: ${prior.responseDescription ?? "unknown reason"}.`,
|
||||
`Already processed: ${prior.responseDescription ?? "failed"}`,
|
||||
);
|
||||
}
|
||||
// FAILED + TRANSIENT: allowed retry — fall through and re-run the settlement.
|
||||
@@ -209,7 +213,7 @@ export class CbeBillService {
|
||||
if (settled) {
|
||||
return mapPaymentFailure(
|
||||
dto,
|
||||
`Invalid transaction reference number ${dto.Cbe_Txn_Ref}.`,
|
||||
"Duplicate transaction ref",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -237,7 +241,7 @@ export class CbeBillService {
|
||||
} catch (err) {
|
||||
if ((err as { code?: string }).code === PG_UNIQUE_VIOLATION) {
|
||||
// Concurrent duplicate of the same attempt lost the insert race.
|
||||
return mapPaymentFailure(dto, "Payment is being processed.");
|
||||
return mapPaymentFailure(dto, "Payment in progress");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -248,7 +252,7 @@ export class CbeBillService {
|
||||
intent = await this.resolveIntent(dto.Bill_Id);
|
||||
|
||||
if (dto.Currency && dto.Currency !== intent.currency) {
|
||||
throw new CbeBillError("Payment currency does not match.", "BUSINESS");
|
||||
throw new CbeBillError("Currency mismatch", "BUSINESS");
|
||||
}
|
||||
|
||||
// Re-run bill-query — fresh, never cached. Last legitimate point for a synchronous
|
||||
@@ -256,7 +260,7 @@ export class CbeBillService {
|
||||
const billQuery = await this.billResolver.billQuery(intent);
|
||||
if (!billQuery.stillPayable) {
|
||||
throw new CbeBillError(
|
||||
reasonToDescription(billQuery.reason, intent.referenceType),
|
||||
reasonToDescription(billQuery.reason),
|
||||
"BUSINESS",
|
||||
);
|
||||
}
|
||||
@@ -267,7 +271,7 @@ export class CbeBillService {
|
||||
Math.abs(amount - intent.amountMinor) >
|
||||
intent.amountMinor * AMOUNT_TOLERANCE
|
||||
) {
|
||||
throw new CbeBillError("Payment amount does not match.", "BUSINESS");
|
||||
throw new CbeBillError("Amount mismatch", "BUSINESS");
|
||||
}
|
||||
|
||||
const paidAt = new Date(dto.Timestamp);
|
||||
@@ -325,10 +329,10 @@ export class CbeBillService {
|
||||
private assertIntentPayable(intent: PaymentIntent): void {
|
||||
if (intent.status === ProviderPaymentStatus.REQUIRES_ACTION) return;
|
||||
if (intent.status === ProviderPaymentStatus.PROCESSING) {
|
||||
throw new CbeBillError("Payment is being processed.", "BUSINESS");
|
||||
throw new CbeBillError("Payment in progress", "BUSINESS");
|
||||
}
|
||||
throw new CbeBillError(
|
||||
reasonToDescription(localReason(intent), intent.referenceType),
|
||||
reasonToDescription(localReason(intent)),
|
||||
"BUSINESS",
|
||||
);
|
||||
}
|
||||
@@ -336,11 +340,11 @@ export class CbeBillService {
|
||||
/** Check digit first (cheap reject), then the unique bill_reference lookup. */
|
||||
private async resolveIntent(billId: string): Promise<PaymentIntent> {
|
||||
if (!this.billReferenceService.isValid(billId)) {
|
||||
throw new CbeBillError("Bill not found.", "BUSINESS");
|
||||
throw new CbeBillError("Bill not found", "BUSINESS");
|
||||
}
|
||||
const intent = await this.intentsRepository.findByBillReference(billId);
|
||||
if (!intent || intent.provider !== ProviderMethod.CBE_BILL) {
|
||||
throw new CbeBillError("Bill not found.", "BUSINESS");
|
||||
throw new CbeBillError("Bill not found", "BUSINESS");
|
||||
}
|
||||
return intent;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export class CbeExceptionFilter implements ExceptionFilter {
|
||||
response.status(HttpStatus.SERVICE_UNAVAILABLE).json({
|
||||
Status: "FAILED",
|
||||
Response_Code: "9",
|
||||
Response_Description: "Service temporarily unavailable.",
|
||||
Response_Description: "Service unavailable",
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -54,7 +54,7 @@ export class CbeExceptionFilter implements ExceptionFilter {
|
||||
: exception.message;
|
||||
response.status(HttpStatus.OK).json({
|
||||
Status: "FAILED",
|
||||
Response_Code: "3",
|
||||
Response_Code: "2",
|
||||
Response_Description: message || "Invalid request",
|
||||
});
|
||||
return;
|
||||
@@ -65,8 +65,8 @@ export class CbeExceptionFilter implements ExceptionFilter {
|
||||
);
|
||||
response.status(HttpStatus.OK).json({
|
||||
Status: "FAILED",
|
||||
Response_Code: "3",
|
||||
Response_Description: "Internal server error.",
|
||||
Response_Code: "2",
|
||||
Response_Description: "Internal error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ export class CbeBillError extends Error {
|
||||
}
|
||||
|
||||
/**
|
||||
* Every failure maps to Response_Code "3" — the AAFDA spec (§2.10, §3.10) defines only
|
||||
* 0 (success), 1 (auth), 3 (business); only the description is specific (plan §6.6).
|
||||
* Every failure maps to Response_Code "2" (per current CBE integration requirement; the
|
||||
* original AAFDA plan used 3); only the description is specific (plan §6.6).
|
||||
*/
|
||||
export function toCbeFailure(err: unknown): {
|
||||
description: string;
|
||||
@@ -26,5 +26,5 @@ export function toCbeFailure(err: unknown): {
|
||||
if (err instanceof CbeBillError) {
|
||||
return { description: err.message, failureClass: err.failureClass };
|
||||
}
|
||||
return { description: "Internal server error.", failureClass: "TRANSIENT" };
|
||||
return { description: "Internal error", failureClass: "TRANSIENT" };
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export function mapPaymentFailure(
|
||||
Cbe_Txn_Ref: request.Cbe_Txn_Ref,
|
||||
Destination_Txn_Ref: "",
|
||||
Status: "FAILED",
|
||||
Response_Code: "3",
|
||||
Response_Code: "2",
|
||||
Response_Description: description,
|
||||
Additional_Fields: [],
|
||||
};
|
||||
|
||||
@@ -48,7 +48,7 @@ export function mapQueryFailure(
|
||||
Transaction_Type: "",
|
||||
Timestamp: new Date().toISOString(),
|
||||
Status: "FAILED",
|
||||
Response_Code: "3",
|
||||
Response_Code: "2",
|
||||
Response_Description: description,
|
||||
Additional_Fields: [],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user