mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-03 12:03:40 +00:00
feat(freight): GL rebook of cancelled wagons, seal+train required on completion, train/voyage in SMS
This commit is contained in:
@@ -14,6 +14,8 @@ import { InvoiceLineRepository } from "./invoice-line.repository";
|
||||
import { PaymentModule } from "../payment/payment.module";
|
||||
import { CompaniesModule } from "../companies/companies.module";
|
||||
import { FilesModule } from "../files/files.module";
|
||||
import { NotificationsModule } from "../notifications/notifications.module";
|
||||
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -24,6 +26,10 @@ import { FilesModule } from "../files/files.module";
|
||||
DocumentsModule,
|
||||
UserTradeAccessModule,
|
||||
FilesModule,
|
||||
// Customer notice when Finance confirms a manual payment. The inbox module
|
||||
// reaches this one back through CompaniesModule, hence forwardRef.
|
||||
NotificationsModule,
|
||||
forwardRef(() => NotificationInboxModule),
|
||||
],
|
||||
controllers: [BillingController, PortalBillingController, PaymentController],
|
||||
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
|
||||
|
||||
@@ -83,6 +83,8 @@ describe("BillingService.generateInvoice", () => {
|
||||
{} as never, // files
|
||||
{ get: () => undefined } as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
{ directSend: jest.fn() } as never, // notifications
|
||||
{ notify: jest.fn() } as never, // inbox
|
||||
);
|
||||
});
|
||||
|
||||
@@ -166,6 +168,8 @@ describe("BillingService.issueMemo", () => {
|
||||
{} as never,
|
||||
{ get: () => undefined } as never,
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
{ directSend: jest.fn() } as never, // notifications
|
||||
{ notify: jest.fn() } as never, // inbox
|
||||
);
|
||||
return { service, manager, savedLines };
|
||||
}
|
||||
@@ -301,6 +305,8 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
||||
{} as never, // files
|
||||
{ get: () => undefined } as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
{ directSend: jest.fn() } as never, // notifications
|
||||
{ notify: jest.fn() } as never, // inbox
|
||||
);
|
||||
|
||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||
@@ -357,6 +363,8 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
||||
{} as never, // files
|
||||
{ get: () => undefined } as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
{ directSend: jest.fn() } as never, // notifications
|
||||
{ notify: jest.fn() } as never, // inbox
|
||||
);
|
||||
|
||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||
@@ -403,6 +411,8 @@ describe("BillingService.settleByPaymentId", () => {
|
||||
{} as never, // files
|
||||
{ get: () => undefined } as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
{ directSend: jest.fn() } as never, // notifications
|
||||
{ notify: jest.fn() } as never, // inbox
|
||||
);
|
||||
return { service, mg, events };
|
||||
}
|
||||
@@ -517,6 +527,8 @@ describe("BillingService.recordPayment", () => {
|
||||
{} as never, // files
|
||||
{ get: () => undefined } as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
{ directSend: jest.fn() } as never, // notifications
|
||||
{ notify: jest.fn() } as never, // inbox
|
||||
);
|
||||
return { service, mg, events };
|
||||
}
|
||||
@@ -635,6 +647,8 @@ describe("BillingService.expirePayable — locked write runs in a transaction",
|
||||
{} as never,
|
||||
{} as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
{ directSend: jest.fn() } as never, // notifications
|
||||
{ notify: jest.fn() } as never, // inbox
|
||||
);
|
||||
return { service, defaultManager, txManager, transaction };
|
||||
};
|
||||
@@ -709,6 +723,8 @@ describe("BillingService.issuePayable", () => {
|
||||
{} as never,
|
||||
{} as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
{ directSend: jest.fn() } as never, // notifications
|
||||
{ notify: jest.fn() } as never, // inbox
|
||||
);
|
||||
return { service, manager };
|
||||
};
|
||||
@@ -801,6 +817,8 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
|
||||
{} as never,
|
||||
{} as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
{ directSend: jest.fn() } as never, // notifications
|
||||
{ notify: jest.fn() } as never, // inbox
|
||||
);
|
||||
return { service, repo };
|
||||
};
|
||||
@@ -885,6 +903,8 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () =>
|
||||
{} as never,
|
||||
{} as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
{ directSend: jest.fn() } as never, // notifications
|
||||
{ notify: jest.fn() } as never, // inbox
|
||||
);
|
||||
return { service, repo };
|
||||
};
|
||||
@@ -955,6 +975,8 @@ describe("BillingService.document", () => {
|
||||
: undefined,
|
||||
} as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
{ directSend: jest.fn() } as never, // notifications
|
||||
{ notify: jest.fn() } as never, // inbox
|
||||
);
|
||||
return { service, render, renderThermal };
|
||||
};
|
||||
@@ -1047,6 +1069,8 @@ describe("BillingService.confirmOfflinePayment pay-window guard", () => {
|
||||
function makeService(invoiceType: string) {
|
||||
const invoice = {
|
||||
id: "inv-1",
|
||||
invoiceNumber: "INV-001",
|
||||
companyId: "company-1",
|
||||
source: Freight.InvoiceSource.Booking,
|
||||
sourceId: "booking-1",
|
||||
type: invoiceType,
|
||||
@@ -1057,9 +1081,16 @@ describe("BillingService.confirmOfflinePayment pay-window guard", () => {
|
||||
const recordPayment = jest.fn().mockResolvedValue(invoice);
|
||||
const dataSource = {
|
||||
getRepository: () => ({
|
||||
findOne: async () => ({ id: "booking-1", paymentDeadline: PAST }),
|
||||
findOne: async () => ({
|
||||
id: "booking-1",
|
||||
reference: "BK-001",
|
||||
paymentDeadline: PAST,
|
||||
}),
|
||||
}),
|
||||
query: async () => [{ phone: "+251900000000", email: "c@x.com" }],
|
||||
};
|
||||
const directSend = jest.fn().mockResolvedValue(undefined);
|
||||
const notify = jest.fn().mockResolvedValue(undefined);
|
||||
const service = new BillingService(
|
||||
dataSource as never,
|
||||
{ findById: async () => invoice } as never,
|
||||
@@ -1071,10 +1102,12 @@ describe("BillingService.confirmOfflinePayment pay-window guard", () => {
|
||||
{ upload: async () => ({ id: "file-1", name: "slip.pdf" }) } as never,
|
||||
{ get: () => undefined } as never,
|
||||
{ isEnabled: async () => true } as never,
|
||||
{ directSend } as never,
|
||||
{ notify } as never,
|
||||
);
|
||||
(service as unknown as { recordPayment: unknown }).recordPayment =
|
||||
recordPayment;
|
||||
return { service, recordPayment };
|
||||
return { service, recordPayment, directSend, notify };
|
||||
}
|
||||
|
||||
const slip = { originalname: "slip.pdf" } as never;
|
||||
@@ -1097,6 +1130,42 @@ describe("BillingService.confirmOfflinePayment pay-window guard", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("notifies the customer (inbox + SMS + email) once the payment is confirmed", async () => {
|
||||
const { service, notify, directSend } = makeService(
|
||||
WAGON_CANCEL_FEE_INVOICE_TYPE,
|
||||
);
|
||||
await service.confirmOfflinePayment("inv-1", slip, {});
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
recipients: { companyId: "company-1" },
|
||||
type: "PAYMENT_RECEIVED",
|
||||
link: "/billing/inv-1",
|
||||
body: expect.stringMatching(/500 ETB .*INV-001 \(booking BK-001\)/),
|
||||
}),
|
||||
);
|
||||
expect(directSend).toHaveBeenCalledWith(
|
||||
"sms",
|
||||
"+251900000000",
|
||||
expect.stringContaining("INV-001"),
|
||||
);
|
||||
expect(directSend).toHaveBeenCalledWith(
|
||||
"email",
|
||||
"c@x.com",
|
||||
expect.stringContaining("INV-001"),
|
||||
);
|
||||
});
|
||||
|
||||
it("still settles when the customer notice fails", async () => {
|
||||
const { service, notify, recordPayment } = makeService(
|
||||
WAGON_CANCEL_FEE_INVOICE_TYPE,
|
||||
);
|
||||
notify.mockRejectedValueOnce(new Error("inbox down"));
|
||||
await expect(
|
||||
service.confirmOfflinePayment("inv-1", slip, {}),
|
||||
).resolves.toBeDefined();
|
||||
expect(recordPayment).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still requires the bank slip for a cancellation fee", async () => {
|
||||
const { service } = makeService(WAGON_CANCEL_FEE_INVOICE_TYPE);
|
||||
await expect(
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { Freight, PaymentReferenceType } from "@edr/types";
|
||||
import {
|
||||
Freight,
|
||||
NotificationAudience,
|
||||
NotificationType,
|
||||
PaymentReferenceType,
|
||||
} from "@edr/types";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import {
|
||||
BadRequestException,
|
||||
@@ -20,6 +25,10 @@ import { WAGON_CANCEL_FEE_INVOICE_TYPE } from "../bookings/entities/booking-wago
|
||||
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
|
||||
import { ShippingLineCredit } from "../shipping-lines/entities/shipping-line-credit.entity";
|
||||
import { ManualPaymentSettingsService } from "../payment-settings/manual-payment-settings.service";
|
||||
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
|
||||
import { NotificationsService } from "../notifications/notifications.service";
|
||||
import { sendCompanyChannels } from "../notifications/notify-company.util";
|
||||
import { resolveShippingLineNotifyTarget } from "../notifications/resolve-shipping-line-contact.util";
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { CompaniesService } from "../companies/companies.service";
|
||||
import { EimsInvoiceStatus } from "../eims/eims-registration.types";
|
||||
@@ -267,6 +276,8 @@ export class BillingService {
|
||||
private readonly files: FilesService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly manualPaymentSettings: ManualPaymentSettingsService,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
) { }
|
||||
|
||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||
@@ -769,8 +780,9 @@ export class BillingService {
|
||||
uploadedByName: input.userName ?? null,
|
||||
});
|
||||
|
||||
return this.recordPayment(invoiceId, {
|
||||
amount: Number(invoice.balanceAmount),
|
||||
const amount = Number(invoice.balanceAmount);
|
||||
const paid = await this.recordPayment(invoiceId, {
|
||||
amount,
|
||||
method: "BANK_TRANSFER",
|
||||
reference: input.reference || slip.name,
|
||||
metadata: {
|
||||
@@ -780,6 +792,104 @@ export class BillingService {
|
||||
confirmedByName: input.userName ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
// The customer did not pay through the portal, so nothing else tells them
|
||||
// Finance has settled their invoice — this is their only confirmation.
|
||||
await this.notifyCustomerManualPaymentConfirmed(paid, amount);
|
||||
return paid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the customer Finance confirmed their manual (bank transfer / counter)
|
||||
* payment: portal inbox entry plus SMS and email to the company's contact
|
||||
* (or the shipping line's own contact for a credit invoice). Best-effort —
|
||||
* a notification failure never undoes the settlement, it is only logged.
|
||||
*/
|
||||
private async notifyCustomerManualPaymentConfirmed(
|
||||
invoice: Invoice,
|
||||
amount: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const bookingRef =
|
||||
invoice.source === Freight.InvoiceSource.Booking
|
||||
? await this.bookingReferenceFor(invoice.sourceId)
|
||||
: null;
|
||||
const body =
|
||||
`Your payment of ${round2(amount)} ${invoice.currency} for invoice ${invoice.invoiceNumber}` +
|
||||
(bookingRef ? ` (booking ${bookingRef})` : "") +
|
||||
` has been received and confirmed. Thank you.`;
|
||||
const title = "Payment confirmed";
|
||||
const data = {
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
bookingId: bookingRef ? invoice.sourceId : null,
|
||||
};
|
||||
|
||||
if (invoice.companyId || invoice.companyProfileId) {
|
||||
await this.inbox.notify({
|
||||
recipients: invoice.companyId
|
||||
? { companyId: invoice.companyId }
|
||||
: { companyProfileId: invoice.companyProfileId! },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.PAYMENT_RECEIVED,
|
||||
title,
|
||||
body,
|
||||
link: `/billing/${invoice.id}`,
|
||||
data,
|
||||
});
|
||||
if (invoice.companyId) {
|
||||
await sendCompanyChannels(
|
||||
this.dataSource,
|
||||
this.notifications,
|
||||
invoice.companyId,
|
||||
body,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (invoice.shippingLineCompanyId) {
|
||||
const target = await resolveShippingLineNotifyTarget(
|
||||
this.dataSource,
|
||||
invoice.shippingLineCompanyId,
|
||||
);
|
||||
if (target.userId) {
|
||||
await this.inbox.notify({
|
||||
recipients: { userIds: [target.userId] },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.PAYMENT_RECEIVED,
|
||||
title,
|
||||
body,
|
||||
link: `/shipping-line/invoices/${invoice.id}`,
|
||||
data,
|
||||
});
|
||||
}
|
||||
for (const [method, to] of [
|
||||
["sms", target.phone],
|
||||
["email", target.email],
|
||||
] as const) {
|
||||
if (!to) continue;
|
||||
try {
|
||||
await this.notifications.directSend(method, to, body);
|
||||
} catch {
|
||||
/* best-effort: provider unavailable */
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Manual payment confirmed notify failed for invoice ${invoice.id}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Booking reference for a booking id, or null when the booking is gone. */
|
||||
private async bookingReferenceFor(bookingId: string): Promise<string | null> {
|
||||
const booking = await this.dataSource.getRepository(Booking).findOne({
|
||||
where: { id: bookingId },
|
||||
select: ["id", "reference"],
|
||||
});
|
||||
return booking?.reference ?? null;
|
||||
}
|
||||
|
||||
/** Invoice header plus its line items. */
|
||||
|
||||
@@ -664,9 +664,11 @@ export class BookingsController {
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
// GL (createBooking) rebooks credits and must see the ledger for that.
|
||||
const staff =
|
||||
hasFreightPermission(user, FREIGHT_PERMS.bookings.view) ||
|
||||
hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView);
|
||||
hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView) ||
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking);
|
||||
if (!staff) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
user?.id,
|
||||
@@ -789,6 +791,14 @@ export class BookingsController {
|
||||
staffPermission: string,
|
||||
): Promise<void> {
|
||||
if (hasFreightPermission(user, staffPermission)) return;
|
||||
// Rebooking a credit creates a booking under the contract — GL's booking
|
||||
// creation key covers it even where the dedicated rebook key was never granted.
|
||||
if (
|
||||
staffPermission === FREIGHT_PERMS.bookings.wagonCancellationRebook &&
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const row = await this.wagonCancellationService.findById(cancellationId);
|
||||
const booking = await this.bookingsService.findById(row.bookingId);
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
|
||||
@@ -2814,6 +2814,11 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.contracts.clearanceReview,
|
||||
FREIGHT_PERMS.contracts.finalizeClearance,
|
||||
FREIGHT_PERMS.contracts.createBooking,
|
||||
// GL rebooks cancelled-wagon credits on the customer's behalf — whoever
|
||||
// cancelled (customer or staff) and whichever side was at fault. Needs to
|
||||
// see the ledger rows and to redeem the credit.
|
||||
FREIGHT_PERMS.bookings.wagonCancellationView,
|
||||
FREIGHT_PERMS.bookings.wagonCancellationRebook,
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
FREIGHT_PERMS.contracts.clearanceDutyAdvise,
|
||||
FREIGHT_PERMS.contracts.finalInvoiceConfirm,
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Group, Modal, Select, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { DatePickerInput } from "@mantine/dates";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { api } from "@/auth/http";
|
||||
import { toDayString } from "@/hooks/useListControls";
|
||||
import { formatMoney } from "@/lib/format";
|
||||
import {
|
||||
hasOddFt20,
|
||||
type RebookPartnerCandidate,
|
||||
type WagonCancellation,
|
||||
} from "./types";
|
||||
|
||||
/** Editable rebook unit — prefilled from the cancelled snapshot. */
|
||||
interface RebookUnitDraft {
|
||||
containerSize: string;
|
||||
containerNumber: string;
|
||||
sealNumber: string;
|
||||
vgmTons: number | "";
|
||||
}
|
||||
|
||||
const draftsFrom = (r: WagonCancellation): RebookUnitDraft[] =>
|
||||
(r.cancelledQuantities?.units ?? []).map((u) => ({
|
||||
containerSize: u.containerSize,
|
||||
containerNumber: u.containerNumber,
|
||||
sealNumber: u.sealNumber ?? "",
|
||||
vgmTons: Number(u.vgmTons) || "",
|
||||
}));
|
||||
|
||||
const containersPayload = (drafts: RebookUnitDraft[]) => {
|
||||
const bySize = new Map<string, RebookUnitDraft[]>();
|
||||
for (const d of drafts) {
|
||||
bySize.set(d.containerSize, [...(bySize.get(d.containerSize) ?? []), d]);
|
||||
}
|
||||
return [...bySize.entries()].map(([containerSize, units]) => ({
|
||||
containerSize,
|
||||
units: units.map((u) => ({
|
||||
containerNumber: u.containerNumber.trim(),
|
||||
...(u.sealNumber.trim() ? { sealNumber: u.sealNumber.trim() } : {}),
|
||||
...(u.vgmTons !== "" ? { vgmTons: Number(u.vgmTons) } : {}),
|
||||
})),
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Staff/GL rebook of a CREDIT_AVAILABLE wagon cancellation: pick the shipment
|
||||
* day, correct container details if they changed, and — for an odd-20ft
|
||||
* credit — pick the consolidation partner that shares the wagon. The server
|
||||
* creates the new booking under the contract and marks it PAID from the credit.
|
||||
* Used by the wagon-cancellations list, the GL clearance page and the staff
|
||||
* booking page, so every desk gets the same flow.
|
||||
*/
|
||||
export function RebookWagonCancellationModal({
|
||||
cancellation,
|
||||
onClose,
|
||||
onRebooked,
|
||||
}: {
|
||||
cancellation: WagonCancellation | null;
|
||||
onClose: () => void;
|
||||
/** Called after a successful rebook with the new booking id (when the API returns it). */
|
||||
onRebooked?: (result: { bookingId?: string }) => void;
|
||||
}) {
|
||||
const [date, setDate] = useState<Date | null>(null);
|
||||
const [partnerId, setPartnerId] = useState<string | null>(null);
|
||||
const [drafts, setDrafts] = useState<RebookUnitDraft[]>([]);
|
||||
|
||||
// Fresh form per row: the modal instance is long-lived on the host page.
|
||||
useEffect(() => {
|
||||
setDate(null);
|
||||
setPartnerId(null);
|
||||
setDrafts(cancellation ? draftsFrom(cancellation) : []);
|
||||
}, [cancellation]);
|
||||
|
||||
const needsPartner = cancellation ? hasOddFt20(cancellation) : false;
|
||||
const partners = useQuery({
|
||||
queryKey: [
|
||||
"wagon-cancellations",
|
||||
cancellation?.id,
|
||||
"rebook-partners",
|
||||
date ? toDayString(date) : null,
|
||||
],
|
||||
enabled: Boolean(cancellation && needsPartner && date),
|
||||
queryFn: async () => {
|
||||
const res = await api.get<RebookPartnerCandidate[]>(
|
||||
`/bookings/wagon-cancellations/${cancellation!.id}/rebook-partners`,
|
||||
{ params: { scheduledDate: toDayString(date!) } },
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const rebook = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await api.post<{ bookingId?: string }>(
|
||||
`/bookings/wagon-cancellations/${cancellation!.id}/rebook`,
|
||||
{
|
||||
scheduledDate: toDayString(date!),
|
||||
...(drafts.length ? { containers: containersPayload(drafts) } : {}),
|
||||
...(partnerId ? { partnerBookingId: partnerId } : {}),
|
||||
},
|
||||
);
|
||||
return res.data ?? {};
|
||||
},
|
||||
});
|
||||
|
||||
const patchDraft = (i: number, patch: Partial<RebookUnitDraft>) =>
|
||||
setDrafts((prev) => prev.map((x, idx) => (idx === i ? { ...x, ...patch } : x)));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={!!cancellation}
|
||||
onClose={onClose}
|
||||
title="Rebook cancelled wagons"
|
||||
centered
|
||||
radius="md"
|
||||
>
|
||||
{cancellation && (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">
|
||||
{cancellation.booking?.reference ?? cancellation.bookingId} ·{" "}
|
||||
{cancellation.wagonsCancelled} wagon(s) · credit{" "}
|
||||
{formatMoney(cancellation.creditAmount, cancellation.feeCurrency, 2)}
|
||||
</Text>
|
||||
<DatePickerInput
|
||||
label="Shipment day"
|
||||
placeholder="Pick the day"
|
||||
value={date}
|
||||
onChange={(v) => {
|
||||
setDate(v ? new Date(v) : null);
|
||||
setPartnerId(null);
|
||||
}}
|
||||
minDate={new Date()}
|
||||
radius="md"
|
||||
/>
|
||||
{needsPartner && (
|
||||
<Select
|
||||
label="Consolidation partner"
|
||||
description="This credit has an odd 20ft container — pick the odd booking that shares its wagon. The rebooked booking is paid; it ships once the partner pays."
|
||||
placeholder={
|
||||
!date
|
||||
? "Pick the day first"
|
||||
: partners.isLoading
|
||||
? "Loading…"
|
||||
: "Pick the partner booking"
|
||||
}
|
||||
data={(partners.data ?? []).map((c) => ({
|
||||
value: c.id,
|
||||
label: `${c.reference} · ${c.companyName ?? "—"} · ${c.ft20Quantity}×20ft`,
|
||||
}))}
|
||||
value={partnerId}
|
||||
onChange={setPartnerId}
|
||||
disabled={!date}
|
||||
searchable
|
||||
radius="md"
|
||||
/>
|
||||
)}
|
||||
{needsPartner &&
|
||||
date &&
|
||||
!partners.isLoading &&
|
||||
(partners.data ?? []).length === 0 && (
|
||||
<Text size="xs" c="orange">
|
||||
No odd-20ft booking rides that day — pick another day or wait for
|
||||
a partner booking.
|
||||
</Text>
|
||||
)}
|
||||
{drafts.length > 0 && (
|
||||
<Stack gap={6}>
|
||||
<Text size="xs" c="dimmed">
|
||||
Correct the container details if they changed — sizes and
|
||||
quantities stay as cancelled.
|
||||
</Text>
|
||||
{drafts.map((d, i) => (
|
||||
<Group key={i} gap={8} wrap="nowrap" align="flex-end">
|
||||
<TextInput
|
||||
label={`${d.containerSize} container`}
|
||||
value={d.containerNumber}
|
||||
onChange={(e) => patchDraft(i, { containerNumber: e.currentTarget.value })}
|
||||
size="xs"
|
||||
radius="md"
|
||||
style={{ flex: 1.4 }}
|
||||
/>
|
||||
<TextInput
|
||||
label="Seal no."
|
||||
value={d.sealNumber}
|
||||
onChange={(e) => patchDraft(i, { sealNumber: e.currentTarget.value })}
|
||||
size="xs"
|
||||
radius="md"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<TextInput
|
||||
label="VGM (t)"
|
||||
type="number"
|
||||
value={d.vgmTons === "" ? "" : String(d.vgmTons)}
|
||||
onChange={(e) => {
|
||||
const raw = e.currentTarget.value;
|
||||
patchDraft(i, { vgmTons: raw === "" ? "" : Number(raw) });
|
||||
}}
|
||||
size="xs"
|
||||
radius="md"
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" radius="md" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
color="green"
|
||||
radius="md"
|
||||
disabled={!date || (needsPartner && !partnerId)}
|
||||
loading={rebook.isPending}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const result = await rebook.mutateAsync();
|
||||
toast.success("Credit rebooked as a new paid booking");
|
||||
onClose();
|
||||
onRebooked?.(result);
|
||||
} catch {
|
||||
// interceptor surfaces the reason
|
||||
}
|
||||
}}
|
||||
>
|
||||
Rebook
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useState } from "react";
|
||||
import { Badge, Button, Group, Stack, Text } from "@mantine/core";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { api } from "@/auth/http";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { formatDate, formatMoney } from "@/lib/format";
|
||||
import { RebookWagonCancellationModal } from "./RebookWagonCancellationModal";
|
||||
import {
|
||||
canRebookWagonCancellations,
|
||||
isRebookableCredit,
|
||||
WAGON_CANCELLATION_STATUS_CHIP,
|
||||
type WagonCancellation,
|
||||
type WagonCancellationListResponse,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Per-booking wagon-cancellation ledger with the GL/staff "Rebook" action.
|
||||
* Rendered on the booking pages GL and staff actually work from, so a credit
|
||||
* never sits unredeemed just because the customer cannot rebook it from the
|
||||
* portal (customs bookings, odd-20ft credits) — whoever cancelled the wagons
|
||||
* and whichever side was at fault. Renders nothing when the booking has no
|
||||
* cancellations.
|
||||
*/
|
||||
export function WagonCancellationCreditCard({
|
||||
bookingId,
|
||||
onRebooked,
|
||||
}: {
|
||||
bookingId: string;
|
||||
onRebooked?: () => void;
|
||||
}) {
|
||||
const { user } = useAuth();
|
||||
const qc = useQueryClient();
|
||||
const canRebook = canRebookWagonCancellations(user);
|
||||
const [rebooking, setRebooking] = useState<WagonCancellation | null>(null);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ["bookings", bookingId, "wagon-cancellations"],
|
||||
queryFn: async () => {
|
||||
const res = await api.get<WagonCancellationListResponse | WagonCancellation[]>(
|
||||
`/bookings/${bookingId}/wagon-cancellations`,
|
||||
);
|
||||
const body = res.data;
|
||||
return Array.isArray(body) ? body : (body?.items ?? []);
|
||||
},
|
||||
});
|
||||
|
||||
const rows = data ?? [];
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionCard
|
||||
icon={RotateCcw}
|
||||
title="Cancelled wagons"
|
||||
subtitle="Credits from cancelled wagons are rebooked here on the customer's behalf."
|
||||
accent="grape"
|
||||
>
|
||||
<Stack gap="sm">
|
||||
{rows.map((r) => {
|
||||
const chip = WAGON_CANCELLATION_STATUS_CHIP[r.status] ?? {
|
||||
label: r.status,
|
||||
color: "gray",
|
||||
};
|
||||
const feeOpen =
|
||||
r.fault === "CUSTOMER" && Number(r.feeAmount) > 0 && !r.feePaidAt;
|
||||
return (
|
||||
<Group key={r.id} justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{Number(r.wagonsCancelled)} wagon(s) · credit{" "}
|
||||
{formatMoney(Number(r.creditAmount), r.feeCurrency, 2)}
|
||||
</Text>
|
||||
<Badge color={chip.color} variant="light" size="sm" radius="md">
|
||||
{chip.label}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
Cancelled {formatDate(r.createdAt)}
|
||||
{r.fault ? ` · ${r.fault === "EDR" ? "EDR fault (no fee)" : "customer fault"}` : ""}
|
||||
{Number(r.feeAmount) > 0
|
||||
? ` · fee ${formatMoney(Number(r.feeAmount), r.feeCurrency, 2)}${
|
||||
r.feePaidAt ? " paid" : " unpaid"
|
||||
}`
|
||||
: ""}
|
||||
{r.reason ? ` · ${r.reason}` : ""}
|
||||
</Text>
|
||||
{r.status === "FEE_PENDING" && (
|
||||
<Text size="xs" c="orange">
|
||||
The customer must pay the cancellation fee from the portal
|
||||
Payments tab before the credit can be rebooked.
|
||||
</Text>
|
||||
)}
|
||||
{isRebookableCredit(r) && feeOpen && (
|
||||
<Text size="xs" c="orange">
|
||||
The cancellation fee invoice is still open — the rebook is
|
||||
allowed once it is paid.
|
||||
</Text>
|
||||
)}
|
||||
{r.rebookedBookingId && (
|
||||
<Text size="xs">
|
||||
Rebooked as{" "}
|
||||
<Link to={`/dashboard/booking-requests/${r.rebookedBookingId}`}>
|
||||
{r.rebookedBooking?.reference ?? r.rebookedBookingId}
|
||||
</Link>
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
{isRebookableCredit(r) && canRebook && (
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="green"
|
||||
leftSection={<RotateCcw size={14} />}
|
||||
onClick={() => setRebooking(r)}
|
||||
>
|
||||
Rebook
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
<RebookWagonCancellationModal
|
||||
cancellation={rebooking}
|
||||
onClose={() => setRebooking(null)}
|
||||
onRebooked={() => {
|
||||
void refetch();
|
||||
void qc.invalidateQueries({ queryKey: ["bookings"] });
|
||||
onRebooked?.();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./types";
|
||||
export * from "./RebookWagonCancellationModal";
|
||||
export * from "./WagonCancellationCreditCard";
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { AuthUser } from "@/auth/types";
|
||||
import { FREIGHT_PERMS, hasPermission, isDjiboutiGl } from "@/lib/permissions";
|
||||
|
||||
export type WagonCancellationStatus =
|
||||
| "FEE_PENDING"
|
||||
| "CREDIT_AVAILABLE"
|
||||
| "REBOOKED"
|
||||
| "WITHDRAWN"
|
||||
| "EXPIRED";
|
||||
|
||||
export 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;
|
||||
fault?: "CUSTOMER" | "EDR" | string | null;
|
||||
status: WagonCancellationStatus;
|
||||
reason?: string | null;
|
||||
rebookedAt?: string | null;
|
||||
createdAt: string;
|
||||
booking?: {
|
||||
id: string;
|
||||
reference: string;
|
||||
customsClearingEnabled?: boolean;
|
||||
company?: { name: string };
|
||||
};
|
||||
rebookedBooking?: { id: string; reference: string };
|
||||
feeInvoice?: { invoiceNumber: string; status: string };
|
||||
cancelledQuantities?: {
|
||||
bySize?: Record<string, number>;
|
||||
units?: Array<{
|
||||
containerSize: string;
|
||||
containerNumber: string;
|
||||
sealNumber?: string | null;
|
||||
vgmTons: number;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WagonCancellationListResponse {
|
||||
items: WagonCancellation[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface RebookPartnerCandidate {
|
||||
id: string;
|
||||
reference: string;
|
||||
companyName: string | null;
|
||||
status: string;
|
||||
scheduledDate: string | null;
|
||||
ft20Quantity: number;
|
||||
}
|
||||
|
||||
export const WAGON_CANCELLATION_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" },
|
||||
};
|
||||
|
||||
/** Odd 20ft in the credit ⇒ the rebooked booking shares a wagon and GL must pick the partner. */
|
||||
export const hasOddFt20 = (r: WagonCancellation): boolean =>
|
||||
Object.entries(r.cancelledQuantities?.bySize ?? {})
|
||||
.filter(([size]) => parseInt(size, 10) === 20)
|
||||
.reduce((sum, [, qty]) => sum + Number(qty || 0), 0) %
|
||||
2 ===
|
||||
1;
|
||||
|
||||
/**
|
||||
* Who may redeem a cancelled-wagon credit from the backoffice: anyone holding
|
||||
* the dedicated rebook key, plus GL Ethiopia through its booking-creation key
|
||||
* (a rebook is a new booking under the contract). GL Djibouti never creates
|
||||
* bookings. Mirrors the API's rebook gate.
|
||||
*/
|
||||
export const canRebookWagonCancellations = (
|
||||
user: AuthUser | null | undefined,
|
||||
): boolean =>
|
||||
hasPermission(user, FREIGHT_PERMS.bookings.wagonCancellationRebook) ||
|
||||
(hasPermission(user, FREIGHT_PERMS.contracts.createBooking) && !isDjiboutiGl(user));
|
||||
|
||||
/** A CREDIT_AVAILABLE row with real credit — whoever cancelled and whoever was at fault. */
|
||||
export const isRebookableCredit = (r: WagonCancellation): boolean =>
|
||||
r.status === "CREDIT_AVAILABLE" && Number(r.creditAmount) > 0;
|
||||
@@ -80,6 +80,7 @@ import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
|
||||
import { WagonCancellationCreditCard } from "@/components/bookings/wagon-cancellation";
|
||||
|
||||
export default function BookingRequestDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -653,6 +654,7 @@ function OverviewPanel({
|
||||
/>
|
||||
<BookingCargoCard booking={booking} />
|
||||
<BookingContainerUnitsCard booking={booking} />
|
||||
<WagonCancellationCreditCard bookingId={booking.id} onRebooked={onRefetch} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from "@/components/bookings/detail";
|
||||
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
|
||||
import { AdditionalDocsRequestCard } from "@/components/bookings/detail/AdditionalDocsRequestCard";
|
||||
import { WagonCancellationCreditCard } from "@/components/bookings/wagon-cancellation";
|
||||
import { ClearancePhaseStepper } from "@/components/contracts/ClearancePhaseStepper";
|
||||
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
||||
import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMilestoneTimeline";
|
||||
@@ -374,6 +375,13 @@ export default function DocumentClearanceDetailPage() {
|
||||
/>
|
||||
{booking ? <BookingCompanyCard booking={booking} /> : null}
|
||||
{booking ? <BookingContractCard booking={booking} /> : null}
|
||||
{/* Cancelled-wagon credits: GL rebooks them for the customer. */}
|
||||
{id ? (
|
||||
<WagonCancellationCreditCard
|
||||
bookingId={id}
|
||||
onRebooked={() => void refetch()}
|
||||
/>
|
||||
) : null}
|
||||
{isPhasedGeneral ? (
|
||||
<PhasedClearanceActionPanel
|
||||
bookingId={id!}
|
||||
|
||||
@@ -33,87 +33,15 @@ import {
|
||||
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;
|
||||
customsClearingEnabled?: boolean;
|
||||
company?: { name: string };
|
||||
};
|
||||
rebookedBooking?: { id: string; reference: string };
|
||||
feeInvoice?: { invoiceNumber: string; status: string };
|
||||
cancelledQuantities?: {
|
||||
bySize?: Record<string, number>;
|
||||
units?: Array<{
|
||||
containerSize: string;
|
||||
containerNumber: string;
|
||||
sealNumber?: string | null;
|
||||
vgmTons: number;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
interface RebookPartnerCandidate {
|
||||
id: string;
|
||||
reference: string;
|
||||
companyName: string | null;
|
||||
status: string;
|
||||
scheduledDate: string | null;
|
||||
ft20Quantity: number;
|
||||
}
|
||||
|
||||
/** Odd 20ft in the credit ⇒ the rebooked booking shares a wagon and GL must pick the partner. */
|
||||
const hasOddFt20 = (r: WagonCancellation): boolean =>
|
||||
Object.entries(r.cancelledQuantities?.bySize ?? {})
|
||||
.filter(([size]) => parseInt(size, 10) === 20)
|
||||
.reduce((sum, [, qty]) => sum + Number(qty || 0), 0) %
|
||||
2 ===
|
||||
1;
|
||||
|
||||
/** Editable rebook unit — prefilled from the cancelled snapshot. */
|
||||
interface RebookUnitDraft {
|
||||
containerSize: string;
|
||||
containerNumber: string;
|
||||
sealNumber: string;
|
||||
vgmTons: number | "";
|
||||
}
|
||||
|
||||
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" },
|
||||
};
|
||||
import {
|
||||
canRebookWagonCancellations,
|
||||
isRebookableCredit,
|
||||
RebookWagonCancellationModal,
|
||||
WAGON_CANCELLATION_STATUS_CHIP as STATUS_CHIP,
|
||||
type WagonCancellation,
|
||||
type WagonCancellationListResponse,
|
||||
type WagonCancellationStatus,
|
||||
} from "@/components/bookings/wagon-cancellation";
|
||||
|
||||
const STATUS_FILTER_OPTIONS = (
|
||||
Object.keys(STATUS_CHIP) as WagonCancellationStatus[]
|
||||
@@ -155,72 +83,11 @@ export default function WagonCancellationsPage() {
|
||||
const [from, setFrom] = useState<Date | null>(null);
|
||||
const [to, setTo] = useState<Date | null>(null);
|
||||
const [voiding, setVoiding] = useState<WagonCancellation | null>(null);
|
||||
// GL rebook of a customs (Path B) credit: pick the day; container number /
|
||||
// seal / VGM may be corrected. Non-customs credits are rebooked by the
|
||||
// customer from the portal.
|
||||
const canRebook = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.bookings.wagonCancellationRebook,
|
||||
);
|
||||
// Rebook of a CREDIT_AVAILABLE row — any desk holding the rebook key, or GL
|
||||
// Ethiopia through its booking-creation key. The modal handles the day pick,
|
||||
// container corrections and the odd-20ft partner choice.
|
||||
const canRebook = canRebookWagonCancellations(user);
|
||||
const [rebooking, setRebooking] = useState<WagonCancellation | null>(null);
|
||||
const [rebookDate, setRebookDate] = useState<Date | null>(null);
|
||||
const [rebookPartnerId, setRebookPartnerId] = useState<string | null>(null);
|
||||
const [rebookDrafts, setRebookDrafts] = useState<RebookUnitDraft[]>([]);
|
||||
const openRebook = (r: WagonCancellation) => {
|
||||
setRebooking(r);
|
||||
setRebookDate(null);
|
||||
setRebookPartnerId(null);
|
||||
setRebookDrafts(
|
||||
(r.cancelledQuantities?.units ?? []).map((u) => ({
|
||||
containerSize: u.containerSize,
|
||||
containerNumber: u.containerNumber,
|
||||
sealNumber: u.sealNumber ?? "",
|
||||
vgmTons: Number(u.vgmTons) || "",
|
||||
})),
|
||||
);
|
||||
};
|
||||
const rebookContainersPayload = () => {
|
||||
const bySize = new Map<string, RebookUnitDraft[]>();
|
||||
for (const d of rebookDrafts) {
|
||||
bySize.set(d.containerSize, [...(bySize.get(d.containerSize) ?? []), d]);
|
||||
}
|
||||
return [...bySize.entries()].map(([containerSize, units]) => ({
|
||||
containerSize,
|
||||
units: units.map((u) => ({
|
||||
containerNumber: u.containerNumber.trim(),
|
||||
...(u.sealNumber.trim() ? { sealNumber: u.sealNumber.trim() } : {}),
|
||||
...(u.vgmTons !== "" ? { vgmTons: Number(u.vgmTons) } : {}),
|
||||
})),
|
||||
}));
|
||||
};
|
||||
const rebook = useMutation({
|
||||
mutationFn: () =>
|
||||
api.post(`/bookings/wagon-cancellations/${rebooking!.id}/rebook`, {
|
||||
scheduledDate: toDayString(rebookDate!),
|
||||
...(rebookDrafts.length ? { containers: rebookContainersPayload() } : {}),
|
||||
...(rebookPartnerId ? { partnerBookingId: rebookPartnerId } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
// Odd-20ft credit: the rebooked booking shares a wagon again, so GL must pick
|
||||
// the odd partner booking riding the chosen day. It ships once that partner pays.
|
||||
const rebookNeedsPartner = rebooking ? hasOddFt20(rebooking) : false;
|
||||
const rebookPartners = useQuery({
|
||||
queryKey: [
|
||||
"wagon-cancellations",
|
||||
rebooking?.id,
|
||||
"rebook-partners",
|
||||
rebookDate ? toDayString(rebookDate) : null,
|
||||
],
|
||||
enabled: Boolean(rebooking && rebookNeedsPartner && rebookDate),
|
||||
queryFn: async () => {
|
||||
const res = await api.get<RebookPartnerCandidate[]>(
|
||||
`/bookings/wagon-cancellations/${rebooking!.id}/rebook-partners`,
|
||||
{ params: { scheduledDate: toDayString(rebookDate!) } },
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const resetPage = () =>
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
@@ -340,14 +207,12 @@ export default function WagonCancellationsPage() {
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
const showVoid = r.status === "FEE_PENDING" && canVoid;
|
||||
// Customs credits are GL's to rebook; non-customs ones the customer
|
||||
// rebooks from the portal — EXCEPT odd-20ft credits: those must be
|
||||
// re-paired with a partner booking, which only GL can pick.
|
||||
const showRebook =
|
||||
r.status === "CREDIT_AVAILABLE" &&
|
||||
canRebook &&
|
||||
(Boolean(r.booking?.customsClearingEnabled) || hasOddFt20(r)) &&
|
||||
Number(r.creditAmount) > 0;
|
||||
// Every available credit is rebookable from here — customs or not,
|
||||
// customer- or staff-cancelled, EDR or customer fault. The customer can
|
||||
// also self-rebook plain non-customs credits from the portal, but GL
|
||||
// must be able to do it for them (customs bookings and odd-20ft
|
||||
// credits are never self-booked). The API enforces the fee gate.
|
||||
const showRebook = isRebookableCredit(r) && canRebook;
|
||||
if (!showVoid && !showRebook) return null;
|
||||
return (
|
||||
<Group justify="flex-end" wrap="nowrap">
|
||||
@@ -357,7 +222,7 @@ export default function WagonCancellationsPage() {
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="green"
|
||||
onClick={() => openRebook(r)}
|
||||
onClick={() => setRebooking(r)}
|
||||
>
|
||||
Rebook
|
||||
</Button>
|
||||
@@ -518,153 +383,11 @@ export default function WagonCancellationsPage() {
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
<Modal
|
||||
opened={!!rebooking}
|
||||
<RebookWagonCancellationModal
|
||||
cancellation={rebooking}
|
||||
onClose={() => setRebooking(null)}
|
||||
title="Rebook cancelled wagons"
|
||||
centered
|
||||
radius="md"
|
||||
>
|
||||
{rebooking && (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">
|
||||
{rebooking.booking?.reference ?? rebooking.bookingId} ·{" "}
|
||||
{rebooking.wagonsCancelled} wagon(s) · credit{" "}
|
||||
{formatMoney(rebooking.creditAmount, rebooking.feeCurrency, 2)}
|
||||
</Text>
|
||||
<DatePickerInput
|
||||
label="Shipment day"
|
||||
placeholder="Pick the day"
|
||||
value={rebookDate}
|
||||
onChange={(v) => {
|
||||
setRebookDate(v ? new Date(v) : null);
|
||||
setRebookPartnerId(null);
|
||||
}}
|
||||
radius="md"
|
||||
/>
|
||||
{rebookNeedsPartner && (
|
||||
<Select
|
||||
label="Consolidation partner"
|
||||
description="This credit has an odd 20ft container — pick the odd booking that shares its wagon. The rebooked booking is paid; it ships once the partner pays."
|
||||
placeholder={
|
||||
!rebookDate
|
||||
? "Pick the day first"
|
||||
: rebookPartners.isLoading
|
||||
? "Loading…"
|
||||
: "Pick the partner booking"
|
||||
}
|
||||
data={(rebookPartners.data ?? []).map((c) => ({
|
||||
value: c.id,
|
||||
label: `${c.reference} · ${c.companyName ?? "—"} · ${c.ft20Quantity}×20ft`,
|
||||
}))}
|
||||
value={rebookPartnerId}
|
||||
onChange={setRebookPartnerId}
|
||||
disabled={!rebookDate}
|
||||
searchable
|
||||
radius="md"
|
||||
/>
|
||||
)}
|
||||
{rebookNeedsPartner &&
|
||||
rebookDate &&
|
||||
!rebookPartners.isLoading &&
|
||||
(rebookPartners.data ?? []).length === 0 && (
|
||||
<Text size="xs" c="orange">
|
||||
No odd-20ft booking rides that day — pick another day or wait
|
||||
for a partner booking.
|
||||
</Text>
|
||||
)}
|
||||
{rebookDrafts.length > 0 && (
|
||||
<Stack gap={6}>
|
||||
<Text size="xs" c="dimmed">
|
||||
Correct the container details if they changed — sizes and
|
||||
quantities stay as cancelled.
|
||||
</Text>
|
||||
{rebookDrafts.map((d, i) => (
|
||||
<Group key={i} gap={8} wrap="nowrap" align="flex-end">
|
||||
<TextInput
|
||||
label={`${d.containerSize} container`}
|
||||
value={d.containerNumber}
|
||||
onChange={(e) => {
|
||||
const v = e.currentTarget.value;
|
||||
setRebookDrafts((prev) =>
|
||||
prev.map((x, idx) =>
|
||||
idx === i ? { ...x, containerNumber: v } : x,
|
||||
),
|
||||
);
|
||||
}}
|
||||
size="xs"
|
||||
radius="md"
|
||||
style={{ flex: 1.4 }}
|
||||
/>
|
||||
<TextInput
|
||||
label="Seal no."
|
||||
value={d.sealNumber}
|
||||
onChange={(e) => {
|
||||
const v = e.currentTarget.value;
|
||||
setRebookDrafts((prev) =>
|
||||
prev.map((x, idx) =>
|
||||
idx === i ? { ...x, sealNumber: v } : x,
|
||||
),
|
||||
);
|
||||
}}
|
||||
size="xs"
|
||||
radius="md"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<TextInput
|
||||
label="VGM (t)"
|
||||
type="number"
|
||||
value={d.vgmTons === "" ? "" : String(d.vgmTons)}
|
||||
onChange={(e) => {
|
||||
const raw = e.currentTarget.value;
|
||||
setRebookDrafts((prev) =>
|
||||
prev.map((x, idx) =>
|
||||
idx === i
|
||||
? { ...x, vgmTons: raw === "" ? "" : Number(raw) }
|
||||
: x,
|
||||
),
|
||||
);
|
||||
}}
|
||||
size="xs"
|
||||
radius="md"
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
onClick={() => setRebooking(null)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
color="green"
|
||||
radius="md"
|
||||
disabled={
|
||||
!rebookDate || (rebookNeedsPartner && !rebookPartnerId)
|
||||
}
|
||||
loading={rebook.isPending}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await rebook.mutateAsync();
|
||||
toast.success("Credit rebooked as a new paid booking");
|
||||
setRebooking(null);
|
||||
void refetch();
|
||||
} catch {
|
||||
// interceptor surfaces the reason
|
||||
}
|
||||
}}
|
||||
>
|
||||
Rebook
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
onRebooked={() => void refetch()}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -311,7 +311,7 @@ export function WagonCancellationCard({
|
||||
{isCustoms || oddFt20Credit ? (
|
||||
<Text fz={13} c="#475569">
|
||||
{isCustoms
|
||||
? "This is a customs-cleared booking — Global Logistics will rebook the credit for you."
|
||||
? "This is a customs-cleared booking — Global Logistics (EDR staff) will rebook the credit for you on a coming train day. Contact them if you have a preferred date."
|
||||
: "Your credit includes an odd 20ft container that must share a wagon with another booking — Global Logistics will rebook it for you and pair the wagon. Please contact EDR staff."}
|
||||
</Text>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user