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:
@@ -0,0 +1,22 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Adds PAYMENT_PROCESSING to the invoice status enum: the customer completed
|
||||
* provider checkout (success redirect) and settlement is awaiting the
|
||||
* provider webhook.
|
||||
*/
|
||||
export class InvoicePaymentProcessingStatus3260000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "InvoicePaymentProcessingStatus3260000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'PAYMENT_PROCESSING' AFTER 'PENDING'`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(): Promise<void> {
|
||||
// Postgres cannot drop an enum value; PAYMENT_PROCESSING stays. Harmless.
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,8 @@ const DEFAULT_DUE_DAYS = 14;
|
||||
const OPEN_STATUSES: Freight.InvoiceStatus[] = [
|
||||
Freight.InvoiceStatus.Issued,
|
||||
Freight.InvoiceStatus.Pending,
|
||||
// Success-redirect ack; still unsettled, so it must stay payable/settleable.
|
||||
Freight.InvoiceStatus.PaymentProcessing,
|
||||
Freight.InvoiceStatus.PartiallyPaid,
|
||||
Freight.InvoiceStatus.Overdue,
|
||||
];
|
||||
@@ -924,6 +926,26 @@ export class BillingService {
|
||||
});
|
||||
if (!invoice) return null;
|
||||
|
||||
// Reconcile-before-expire, caller-proof: an invoice with a payment intent may
|
||||
// have settled at the gateway without the webhook landing yet. `paid` — leave
|
||||
// it open, the (re-emitted) payment.succeeded settles it. `unverifiable` —
|
||||
// never expire on unknown; the caller's next sweep retries. Invoices with no
|
||||
// intent (`paymentId` null) were never payable at a gateway and expire directly.
|
||||
if (invoice.paymentId) {
|
||||
const { paid, unverifiable } = await this.reconcilePayable(
|
||||
invoice.sourceId,
|
||||
);
|
||||
if (paid || unverifiable) {
|
||||
this.logger.warn(
|
||||
`expirePayable skipped for invoice ${invoice.invoiceNumber} (${invoice.id}) — ` +
|
||||
(paid
|
||||
? "gateway reconcile found a settled payment"
|
||||
: "settlement unverifiable at the gateway"),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return this.transition(
|
||||
invoice.id,
|
||||
Freight.InvoiceStatus.Expired,
|
||||
@@ -1028,6 +1050,40 @@ export class BillingService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Success-redirect ack (see PaymentService.acknowledgeSuccessRedirect): move
|
||||
* the invoice linked to a gateway intent to PAYMENT_PROCESSING. Only from
|
||||
* ISSUED/PENDING — never overwrites a settlement (PAID/PARTIALLY_PAID) and
|
||||
* is idempotent. Balance untouched: this is a display state, not a
|
||||
* settlement; settleByPaymentId still performs the real transition.
|
||||
*/
|
||||
async markInvoicePaymentProcessing(paymentId: string): Promise<void> {
|
||||
await this.dataSource.getRepository(Invoice).update(
|
||||
{
|
||||
paymentId,
|
||||
status: In([
|
||||
Freight.InvoiceStatus.Issued,
|
||||
Freight.InvoiceStatus.Pending,
|
||||
]),
|
||||
},
|
||||
{ status: Freight.InvoiceStatus.PaymentProcessing },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Counterpart of {@link markInvoicePaymentProcessing} for a failed intent:
|
||||
* PAYMENT_PROCESSING → PENDING so the invoice reads payable again for a
|
||||
* retry. No-op from any other status.
|
||||
*/
|
||||
async revertInvoicePaymentProcessing(paymentId: string): Promise<void> {
|
||||
await this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.update(
|
||||
{ paymentId, status: Freight.InvoiceStatus.PaymentProcessing },
|
||||
{ status: Freight.InvoiceStatus.Pending },
|
||||
);
|
||||
}
|
||||
|
||||
// ── Payment initiation & settlement (the gateway boundary) ───────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -87,6 +87,33 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
expect(result.lineItems[0].amount).toBe(Math.round(35 * 120 * MOCK_CBE_RATE));
|
||||
});
|
||||
|
||||
it('keeps the exchange rate decimals — ETB amounts round to cents, not whole birr', async () => {
|
||||
exchangeService.getRate.mockResolvedValue(162.2132);
|
||||
const booking = {
|
||||
id: 'b-1-frac',
|
||||
freightType: 'BULK',
|
||||
tradeDirection: 'DOMESTIC',
|
||||
paymentCurrency: 'ETB',
|
||||
cargoTotalWeightVgm: 120,
|
||||
originYardId: MOJO,
|
||||
destinationYardId: DIRE,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
const result = await (
|
||||
service as unknown as {
|
||||
computeBaseRailLinesWithRates: (
|
||||
b: Booking,
|
||||
input: { containers: [] },
|
||||
) => Promise<{ lineItems: Array<{ amount: number }> }>;
|
||||
}
|
||||
).computeBaseRailLinesWithRates(booking, { containers: [] });
|
||||
|
||||
// 35 × 120 × 162.2132 = 681,295.44 — the .44 must survive (whole-birr
|
||||
// rounding here billed with the integer part of the rate, in effect).
|
||||
expect(result.lineItems[0].amount).toBe(681295.44);
|
||||
});
|
||||
|
||||
it('prices domestic bulk in USD using INTERCITY_BULK USD rate directly', async () => {
|
||||
const booking = {
|
||||
id: 'b-1-usd',
|
||||
|
||||
@@ -6,6 +6,7 @@ import { RatesService } from '../rule-engine/services/rates.service';
|
||||
import { Rate } from '../rule-engine/entities/rate.entity';
|
||||
import { isBulkQuantityUnit } from '../rule-engine/entities/rate-unit.util';
|
||||
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
|
||||
import { round2 } from '../billing/invoice-settlement.util';
|
||||
import { ExchangeService } from '@edr/api-common';
|
||||
import {
|
||||
AppliedCargoModifier,
|
||||
@@ -205,14 +206,14 @@ export class BookingPricingService {
|
||||
const unitAmount = frozen
|
||||
? Number(frozen.unitPrice)
|
||||
: isEtbBooking
|
||||
? Math.round(unitUsd * usdToEtb)
|
||||
? round2(unitUsd * usdToEtb)
|
||||
: unitUsd;
|
||||
const convertedAmount = frozen
|
||||
? isEtbBooking
|
||||
? Math.round(unitAmount * quantity)
|
||||
? round2(unitAmount * quantity)
|
||||
: unitAmount * quantity
|
||||
: isEtbBooking
|
||||
? Math.round(usdAmount * usdToEtb)
|
||||
? round2(usdAmount * usdToEtb)
|
||||
: usdAmount;
|
||||
|
||||
const item: PriceLineItemDto = {
|
||||
@@ -583,8 +584,8 @@ export class BookingPricingService {
|
||||
} else {
|
||||
const unitUsd = Number(rate!.rateValue);
|
||||
const usdAmount = this.amountForRate(rate!, container.quantity, lineWagons);
|
||||
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
|
||||
amount = isEtbBooking ? round2(usdAmount * usdToEtb) : usdAmount;
|
||||
unitAmount = isEtbBooking ? round2(unitUsd * usdToEtb) : unitUsd;
|
||||
}
|
||||
if (rate) usedRatesMap.set(rate.id, rate);
|
||||
lines.push({
|
||||
@@ -652,8 +653,8 @@ export class BookingPricingService {
|
||||
);
|
||||
} else {
|
||||
const usdAmount = this.amountForRate(fallback, quantity, wagonCount);
|
||||
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
|
||||
amount = isEtbBooking ? round2(usdAmount * usdToEtb) : usdAmount;
|
||||
unitAmount = isEtbBooking ? round2(unitUsd * usdToEtb) : unitUsd;
|
||||
}
|
||||
lines.push({
|
||||
code: rateType,
|
||||
@@ -763,12 +764,12 @@ export class BookingPricingService {
|
||||
if (frozen) {
|
||||
unitAmount = Number(frozen.unitPrice);
|
||||
amount = isEtbBooking
|
||||
? Math.round(unitAmount * quantity)
|
||||
? round2(unitAmount * quantity)
|
||||
: unitAmount * quantity;
|
||||
} else {
|
||||
const usdAmount = value * quantity;
|
||||
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
unitAmount = isEtbBooking ? Math.round(value * usdToEtb) : value;
|
||||
amount = isEtbBooking ? round2(usdAmount * usdToEtb) : usdAmount;
|
||||
unitAmount = isEtbBooking ? round2(value * usdToEtb) : value;
|
||||
}
|
||||
// Skip legs that resolve to nothing (zero rate, or zero km / count / tons).
|
||||
if (!(amount > 0)) continue;
|
||||
@@ -971,7 +972,7 @@ export class BookingPricingService {
|
||||
if (!(usdToEtb > 0)) return null;
|
||||
const converted =
|
||||
snap.currency === 'USD' && bookingCurrency === 'ETB'
|
||||
? Math.round(unitPrice * usdToEtb)
|
||||
? round2(unitPrice * usdToEtb)
|
||||
: snap.currency === 'ETB' && bookingCurrency === 'USD'
|
||||
? unitPrice / usdToEtb
|
||||
: null;
|
||||
@@ -1027,7 +1028,7 @@ export class BookingPricingService {
|
||||
const currency = booking.paymentCurrency;
|
||||
const isEtb = currency === 'ETB';
|
||||
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||
const convert = (usd: number): number => (isEtb ? Math.round(usd * usdToEtb) : usd);
|
||||
const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd);
|
||||
|
||||
const onLeg = liveRates.filter(
|
||||
(r) =>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, UnprocessableEntityException } from '@nestjs/common';
|
||||
|
||||
import { RatesService } from '../rule-engine/services/rates.service';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
import { round2 } from '../billing/invoice-settlement.util';
|
||||
import { ExchangeService } from '@edr/api-common';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
@@ -79,7 +80,7 @@ export class ContractPricingService {
|
||||
const currency = contract.paymentCurrency;
|
||||
const isEtb = currency === 'ETB';
|
||||
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||
const convert = (usd: number): number => (isEtb ? Math.round(usd * usdToEtb) : usd);
|
||||
const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd);
|
||||
|
||||
const lineItems: ContractUnitRateLineItem[] = [];
|
||||
const baseType = this.baseRateType(contract);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
} from "@nestjs/common";
|
||||
@@ -84,6 +85,15 @@ export class PaymentController {
|
||||
return this.paymentService.getIntentByBookingId(bookingId);
|
||||
}
|
||||
|
||||
@Post("redirect-success/:bookingId")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Success-redirect ack: mark payment processing + invoice PAYMENT_PROCESSING (webhook remains source of truth)",
|
||||
})
|
||||
acknowledgeSuccessRedirect(@Param("bookingId") bookingId: string) {
|
||||
return this.paymentService.acknowledgeSuccessRedirect(bookingId);
|
||||
}
|
||||
|
||||
@Get("receipt/:orderId")
|
||||
@Public()
|
||||
@ApiOperation({ summary: "Generate a payment receipt HTML page" })
|
||||
|
||||
@@ -253,8 +253,10 @@ export class PaymentService {
|
||||
payerAccount: input.payerAccount,
|
||||
payerName: input.payerName,
|
||||
expiresAt: input.expiresAt,
|
||||
// bookingId lets the success page ack the redirect (→ PAYMENT_PROCESSING).
|
||||
returnUrl:
|
||||
input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success",
|
||||
input.returnUrl ??
|
||||
`https://edrfreight.triaplc.com/payment/success?bookingId=${encodeURIComponent(input.referenceId)}`,
|
||||
failureUrl:
|
||||
input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure",
|
||||
});
|
||||
@@ -492,6 +494,37 @@ export class PaymentService {
|
||||
return { alreadyFinalized: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Success-redirect ack from the portal: the customer finished provider
|
||||
* checkout, settlement webhook not (necessarily) in yet. Optimistic
|
||||
* intermediate only — the webhook stays the source of truth. Never
|
||||
* downgrades: only action-required → processing, and the invoice moves to
|
||||
* PAYMENT_PROCESSING only from an open unpaid status. CBE_BILL is excluded
|
||||
* (bank-counter flow, it has no redirect).
|
||||
*/
|
||||
async acknowledgeSuccessRedirect(
|
||||
referenceId: string,
|
||||
): Promise<{ acknowledged: boolean }> {
|
||||
const intent = await this.paymentRepo.findOneBy({ refId: referenceId });
|
||||
if (!intent || intent.method === "cbe-bill") {
|
||||
return { acknowledged: false };
|
||||
}
|
||||
|
||||
if (intent.status === "action-required") {
|
||||
await this.paymentRepo.update(
|
||||
{ id: intent.id, status: "action-required" },
|
||||
{ status: "processing" },
|
||||
);
|
||||
}
|
||||
// Even if the intent already advanced (e.g. webhook raced the redirect to
|
||||
// "processing"), the invoice ack is idempotent and status-guarded.
|
||||
if (intent.status === "action-required" || intent.status === "processing") {
|
||||
await this.billing.markInvoicePaymentProcessing(intent.id);
|
||||
return { acknowledged: true };
|
||||
}
|
||||
return { acknowledged: false };
|
||||
}
|
||||
|
||||
async markPaymentFailed(input: {
|
||||
intentId: string;
|
||||
failureCode?: string;
|
||||
@@ -510,7 +543,9 @@ export class PaymentService {
|
||||
},
|
||||
);
|
||||
|
||||
// Invoice stays open for retry — nothing to settle. Logged only.
|
||||
// Invoice stays open for retry — nothing to settle. A redirect-acked
|
||||
// PAYMENT_PROCESSING invoice is put back to PENDING so it reads payable.
|
||||
await this.billing.revertInvoicePaymentProcessing(intent.id);
|
||||
this.logger.warn(
|
||||
`Payment ${intent.id} failed for ${intent.refId}` +
|
||||
(input.failureMessage ? `: ${input.failureMessage}` : ""),
|
||||
|
||||
@@ -115,6 +115,7 @@ import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2De
|
||||
import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage";
|
||||
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
|
||||
import TradeAccessPage from "./pages/configuration/TradeAccessPage";
|
||||
import ExchangeRateSettingsCard from "./pages/settings/ExchangeRateSettingsCard";
|
||||
import ContractValidityPeriodsPage from "./pages/configuration/ContractValidityPeriodsPage";
|
||||
import FirstMilePage from "./pages/operations/FirstMilePage";
|
||||
import LastMilePage from "./pages/operations/LastMilePage";
|
||||
@@ -597,6 +598,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
href: "/dashboard/configuration/trade-access",
|
||||
permission: FREIGHT_PERMS.admin,
|
||||
},
|
||||
{
|
||||
label: "Exchange rate",
|
||||
href: "/dashboard/configuration/exchange-rate",
|
||||
permission: FREIGHT_PERMS.admin,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -1628,6 +1634,16 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="configuration/exchange-rate"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.admin}>
|
||||
<div className="p-4">
|
||||
<ExchangeRateSettingsCard />
|
||||
</div>
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
{/* <Route
|
||||
path="configuration/contract-validity-periods"
|
||||
element={
|
||||
|
||||
@@ -249,6 +249,7 @@ const INVOICE_STATUS_COLOR: Record<Freight.InvoiceStatus, string> = {
|
||||
DRAFT: "gray",
|
||||
ISSUED: "cyan",
|
||||
PENDING: "yellow",
|
||||
PAYMENT_PROCESSING: "indigo",
|
||||
PARTIALLY_PAID: "orange",
|
||||
PAID: "edr-green",
|
||||
OVERDUE: "red",
|
||||
|
||||
@@ -14,6 +14,7 @@ import { openPdfBlob } from './pdf';
|
||||
const INVOICE_STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
|
||||
DRAFT: 'gray',
|
||||
ISSUED: 'orange',
|
||||
PAYMENT_PROCESSING: 'indigo',
|
||||
PARTIALLY_PAID: 'yellow',
|
||||
PAID: 'edr-green',
|
||||
CANCELLED: 'gray',
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/common/ui/alert-dialog";
|
||||
import { toast } from "sonner";
|
||||
import ExchangeRateSettingsCard from "./settings/ExchangeRateSettingsCard";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
@@ -78,8 +77,6 @@ export default function SettingsPage() {
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<ExchangeRateSettingsCard />
|
||||
|
||||
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl font-semibold">
|
||||
|
||||
@@ -183,6 +183,7 @@ export default function InvoicesPage() {
|
||||
data={[
|
||||
{ label: "All", value: "all" },
|
||||
{ label: "Pending", value: "PENDING" },
|
||||
{ label: "Payment processing", value: "PAYMENT_PROCESSING" },
|
||||
{ label: "Paid", value: "PAID" },
|
||||
{ label: "Overdue", value: "OVERDUE" },
|
||||
]}
|
||||
|
||||
@@ -79,6 +79,7 @@ const INVOICE_STATUS_META: Record<string, { label: string; color: string }> = {
|
||||
PAID: { label: "Paid", color: "green" },
|
||||
PARTIALLY_PAID: { label: "Partially Paid", color: "teal" },
|
||||
PENDING: { label: "Pending", color: "yellow" },
|
||||
PAYMENT_PROCESSING: { label: "Payment Processing", color: "indigo" },
|
||||
UNPAID: { label: "Unpaid", color: "yellow" },
|
||||
OPEN: { label: "Open", color: "yellow" },
|
||||
ISSUED: { label: "Issued", color: "blue" },
|
||||
|
||||
@@ -86,6 +86,7 @@ const INVOICE_STATUS_META: Record<string, { label: string; color: string }> = {
|
||||
PAID: { label: "Paid", color: "green" },
|
||||
PARTIALLY_PAID: { label: "Partially Paid", color: "teal" },
|
||||
PENDING: { label: "Pending", color: "yellow" },
|
||||
PAYMENT_PROCESSING: { label: "Payment Processing", color: "indigo" },
|
||||
UNPAID: { label: "Unpaid", color: "yellow" },
|
||||
OPEN: { label: "Open", color: "yellow" },
|
||||
ISSUED: { label: "Issued", color: "blue" },
|
||||
|
||||
@@ -75,6 +75,7 @@ const CONTRACT_STATUSES = [
|
||||
const INVOICE_STATUSES = [
|
||||
"ISSUED",
|
||||
"PENDING",
|
||||
"PAYMENT_PROCESSING",
|
||||
"PARTIALLY_PAID",
|
||||
"PAID",
|
||||
"OVERDUE",
|
||||
|
||||
@@ -42,6 +42,7 @@ import { extractErrorMessage } from '@/components/warehouses/options';
|
||||
const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
|
||||
DRAFT: 'gray',
|
||||
ISSUED: 'orange',
|
||||
PAYMENT_PROCESSING: 'indigo',
|
||||
PARTIALLY_PAID: 'yellow',
|
||||
PAID: 'edr-green',
|
||||
CANCELLED: 'gray',
|
||||
|
||||
@@ -907,6 +907,7 @@ export interface AllocationCriteria {
|
||||
export const WAREHOUSE_INVOICE_STATUSES = [
|
||||
'DRAFT',
|
||||
'ISSUED',
|
||||
'PAYMENT_PROCESSING',
|
||||
'PARTIALLY_PAID',
|
||||
'PAID',
|
||||
'CANCELLED',
|
||||
|
||||
@@ -194,6 +194,8 @@ export const URL_CONSTANTS = {
|
||||
PAYMENTS: {
|
||||
INITIATE: "/api/payments/initiate",
|
||||
INTENT: (bookingId: string) => `/api/payments/intents/${bookingId}`,
|
||||
REDIRECT_SUCCESS: (bookingId: string) =>
|
||||
`/api/payments/redirect-success/${bookingId}`,
|
||||
CHECKOUT: "/api/payments/checkout",
|
||||
},
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ const STATUS_STYLE: Record<
|
||||
[Freight.InvoiceStatus.Draft]: { label: "Draft", bg: "#EEF2F6", fg: "#64748B" },
|
||||
[Freight.InvoiceStatus.Issued]: { label: "Due", bg: "#FEF3E2", fg: "#B45309" },
|
||||
[Freight.InvoiceStatus.Pending]: { label: "Due", bg: "#FEF3E2", fg: "#B45309" },
|
||||
[Freight.InvoiceStatus.PaymentProcessing]: { label: "Payment processing", bg: "#EAF1FB", fg: "#2563EB" },
|
||||
[Freight.InvoiceStatus.PartiallyPaid]: { label: "Partially paid", bg: "#FEF9E7", fg: "#A16207" },
|
||||
[Freight.InvoiceStatus.Paid]: { label: "Paid", bg: "#E6F7EF", fg: "#0A6F4D" },
|
||||
[Freight.InvoiceStatus.Overdue]: { label: "Overdue", bg: "#FDECEC", fg: "#C0392B" },
|
||||
|
||||
@@ -41,6 +41,7 @@ const INVOICE_STATUS_LABELS: Record<string, string> = {
|
||||
DRAFT: "Draft",
|
||||
ISSUED: "Issued",
|
||||
PENDING: "Due",
|
||||
PAYMENT_PROCESSING: "Payment processing",
|
||||
PARTIALLY_PAID: "Partially paid",
|
||||
PAID: "Paid",
|
||||
OVERDUE: "Overdue",
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { Group, Tabs } from "@mantine/core";
|
||||
import { Clock, CreditCard, FileText, LayoutGrid, Truck } from "lucide-react";
|
||||
import {
|
||||
Clock,
|
||||
CreditCard,
|
||||
FileText,
|
||||
LayoutGrid,
|
||||
Package,
|
||||
Truck,
|
||||
} from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
@@ -9,6 +16,7 @@ import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
|
||||
import { ActivityCard } from "./components/ActivityCard";
|
||||
import { ClearanceCard } from "./components/ClearanceCard";
|
||||
import { BookingClearanceWorkflowBanner } from "@/pages/bookings/BookingClearanceWorkflowBanner";
|
||||
import { CargoTab } from "./components/CargoTab";
|
||||
import { DocumentsTab } from "./components/DocumentsTab";
|
||||
import { CompanyInfoCard } from "./components/CompanyInfoCard";
|
||||
import { ContainersCard } from "./components/ContainersCard";
|
||||
@@ -199,6 +207,9 @@ export function ReadonlyBookingView({
|
||||
<Tabs.Tab value="overview" leftSection={<LayoutGrid size={15} />}>
|
||||
Overview
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="cargo" leftSection={<Package size={15} />}>
|
||||
Cargo
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="logistics" leftSection={<Truck size={15} />}>
|
||||
Logistics
|
||||
</Tabs.Tab>
|
||||
@@ -254,6 +265,10 @@ export function ReadonlyBookingView({
|
||||
</div>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="cargo">
|
||||
<CargoTab booking={booking} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="logistics">
|
||||
<div className="flex flex-col gap-6">
|
||||
<BodyGrid
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
import { Box, Group, SimpleGrid, Table, Text } from "@mantine/core";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Box as BoxIcon,
|
||||
Container,
|
||||
Flame,
|
||||
Package,
|
||||
Scale,
|
||||
Snowflake,
|
||||
Undo2,
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type {
|
||||
BookingContainerLineDetail,
|
||||
BookingContainerUnitDetail,
|
||||
BookingDetail,
|
||||
} from "../booking-detail-types";
|
||||
import {
|
||||
commodityLabel,
|
||||
fmtDate,
|
||||
fmtWeight,
|
||||
shippingLineLabel,
|
||||
totalVgmTons,
|
||||
} from "../utils";
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
|
||||
// ─── Shared bits ──────────────────────────────────────────────────────────────
|
||||
|
||||
function Flag({
|
||||
icon,
|
||||
label,
|
||||
tone = "grey",
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
tone?: "grey" | "amber" | "blue" | "green";
|
||||
}) {
|
||||
const palette = {
|
||||
grey: { bg: "#F1F4F7", color: "#475569" },
|
||||
amber: { bg: "#FFFBEB", color: "#92400E" },
|
||||
blue: { bg: "#EAF1FE", color: "#1E40AF" },
|
||||
green: { bg: "#E8F5EF", color: "#0A6F4D" },
|
||||
}[tone];
|
||||
return (
|
||||
<Group
|
||||
component="span"
|
||||
gap={4}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
borderRadius: 999,
|
||||
backgroundColor: palette.bg,
|
||||
padding: "3px 9px",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
color: palette.color,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<Group justify="space-between" align="baseline" py={11} wrap="nowrap" style={{ borderBottom: "1px solid #F2F5F8" }}>
|
||||
<Text fz={12.5} fw={600} c="#9AA8B5" style={{ flexShrink: 0 }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fz={13.5} fw={700} c="#10202F" ta="right">
|
||||
{value}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
const th = { color: "#9AA8B5", fontSize: 11 } as const;
|
||||
|
||||
// ─── Containers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function lineTypeLabel(line: BookingContainerLineDetail): string {
|
||||
const t = line.containerType;
|
||||
if (t?.label) return t.label;
|
||||
if (t?.sizeFt) return `${t.sizeFt}ft${t.isReefer ? " Reefer" : ""} container`;
|
||||
return t?.code ?? "Container";
|
||||
}
|
||||
|
||||
function UnitRow({ unit }: { unit: BookingContainerUnitDetail }) {
|
||||
return (
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Text fz={13} fw={700} c="#10202F" style={{ fontFamily: "monospace" }}>
|
||||
{unit.containerNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={13} c="#475569">
|
||||
{unit.sealNumber || "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={13} c="#475569">
|
||||
{Number(unit.vgmTons || 0) ? fmtWeight(Number(unit.vgmTons)) : "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4} wrap="wrap">
|
||||
{unit.isHazardous && (
|
||||
<Flag tone="amber" icon={<AlertTriangle size={11} />} label="Hazardous" />
|
||||
)}
|
||||
{unit.isReefer && (
|
||||
<Flag tone="blue" icon={<Snowflake size={11} />} label="Reefer" />
|
||||
)}
|
||||
{unit.isReturn && <Flag icon={<Undo2 size={11} />} label="Return" />}
|
||||
{!unit.isHazardous && !unit.isReefer && !unit.isReturn && (
|
||||
<Text fz={12} c="#9AA8B5">
|
||||
—
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={13} c="#475569">
|
||||
{unit.grnNumber || "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{unit.receivedToPort ? (
|
||||
<Box>
|
||||
<Text fz={12.5} fw={700} c="#0A6F4D">
|
||||
Received
|
||||
</Text>
|
||||
{unit.receivedAt && (
|
||||
<Text fz={11.5} c="#9AA8B5">
|
||||
{fmtDate(unit.receivedAt)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Text fz={12.5} fw={600} c="#9AA8B5">
|
||||
Pending
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
|
||||
function ContainerLineCard({ line, index }: { line: BookingContainerLineDetail; index: number }) {
|
||||
const units = line.units ?? [];
|
||||
return (
|
||||
<SectionCard>
|
||||
<Group justify="space-between" align="flex-start" mb={4} 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",
|
||||
}}
|
||||
>
|
||||
<Container size={18} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz={15} fw={800} c="#10202F">
|
||||
{lineTypeLabel(line)}
|
||||
</Text>
|
||||
<Text fz={12} c="#9AA8B5">
|
||||
Line {index + 1} · {line.quantity} unit{line.quantity !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap={4} wrap="wrap" justify="flex-end">
|
||||
{line.isOverweight && (
|
||||
<Flag
|
||||
tone="amber"
|
||||
icon={<AlertTriangle size={11} />}
|
||||
label={
|
||||
Number(line.overweightExcessTons || 0)
|
||||
? `Overweight +${Number(line.overweightExcessTons)} t`
|
||||
: "Overweight"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!!line.hazardousQuantity && (
|
||||
<Flag tone="amber" icon={<Flame size={11} />} label={`${line.hazardousQuantity} hazardous`} />
|
||||
)}
|
||||
{!!line.reeferQuantity && (
|
||||
<Flag tone="blue" icon={<Snowflake size={11} />} label={`${line.reeferQuantity} reefer`} />
|
||||
)}
|
||||
{!!line.returnQuantity && (
|
||||
<Flag icon={<Undo2 size={11} />} label={`${line.returnQuantity} return`} />
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing={10} my="md">
|
||||
<StatTile
|
||||
icon={<BoxIcon size={13} />}
|
||||
label="Quantity"
|
||||
value={`${line.quantity}`}
|
||||
sub={`container${line.quantity !== 1 ? "s" : ""}`}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<Scale size={13} />}
|
||||
label="VGM / unit"
|
||||
value={fmtWeight(Number(line.vgmPerUnitTons || 0))}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<Scale size={13} />}
|
||||
label="Line total VGM"
|
||||
value={fmtWeight(Number(line.totalVgmTons || 0))}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
{units.length > 0 && (
|
||||
<Box style={{ overflowX: "auto" }}>
|
||||
<Table verticalSpacing="sm" horizontalSpacing="sm" miw={640}>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th style={th}>Container no.</Table.Th>
|
||||
<Table.Th style={th}>Seal no.</Table.Th>
|
||||
<Table.Th style={th}>VGM</Table.Th>
|
||||
<Table.Th style={th}>Flags</Table.Th>
|
||||
<Table.Th style={th}>GRN</Table.Th>
|
||||
<Table.Th style={th}>Port status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{units.map((u) => (
|
||||
<UnitRow key={u.id} unit={u} />
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
)}
|
||||
{units.length === 0 && (
|
||||
<Text fz={12.5} c="#9AA8B5">
|
||||
Container numbers will appear here once the physical units are assigned.
|
||||
</Text>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Bulk ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function BulkCargoCard({ booking }: { booking: BookingDetail }) {
|
||||
const unit = booking.cargoType?.unitOfMeasure;
|
||||
const isPerItem = unit === "PER_ITEM";
|
||||
// Break-bulk (PER_ITEM): cargoTotalWeightVgm holds the ITEM COUNT and the
|
||||
// real tonnage lives in bulkTotalWeightTons; PER_TON stores tons directly.
|
||||
const quantity = Number(booking.cargoTotalWeightVgm || 0);
|
||||
const tons = totalVgmTons(booking);
|
||||
|
||||
return (
|
||||
<SectionCard>
|
||||
<Group gap={10} align="center" mb="md">
|
||||
<Box
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 10,
|
||||
backgroundColor: "#E8F5EF",
|
||||
color: "#0A6F4D",
|
||||
}}
|
||||
>
|
||||
<Package size={18} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz={15} fw={800} c="#10202F">
|
||||
{commodityLabel(booking)}
|
||||
</Text>
|
||||
<Text fz={12} c="#9AA8B5">
|
||||
Bulk cargo{booking.cargoType?.code ? ` · ${booking.cargoType.code}` : ""}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: isPerItem ? 3 : 2 }} spacing={10} mb="md">
|
||||
{isPerItem && (
|
||||
<StatTile
|
||||
icon={<BoxIcon size={13} />}
|
||||
label="Items"
|
||||
value={quantity ? quantity.toLocaleString() : "—"}
|
||||
sub="declared item count"
|
||||
/>
|
||||
)}
|
||||
<StatTile
|
||||
icon={<Scale size={13} />}
|
||||
label="Total weight"
|
||||
value={fmtWeight(tons)}
|
||||
sub={isPerItem ? "actual tonnage" : "declared tonnage"}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<Package size={13} />}
|
||||
label="Billing unit"
|
||||
value={isPerItem ? "Per item" : "Per ton"}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Box>
|
||||
<DetailRow label="Commodity" value={commodityLabel(booking)} />
|
||||
{booking.cargoType?.code && (
|
||||
<DetailRow label="Cargo type code" value={booking.cargoType.code} />
|
||||
)}
|
||||
{booking.cargoFreeText && booking.cargoType?.cargoTypeName && (
|
||||
<DetailRow label="Cargo description" value={booking.cargoFreeText} />
|
||||
)}
|
||||
<DetailRow
|
||||
label="Hazardous"
|
||||
value={
|
||||
Number(booking.bulkHazardousQuantity || 0)
|
||||
? `${Number(booking.bulkHazardousQuantity).toLocaleString()} ${isPerItem ? "items" : "t"}`
|
||||
: booking.isHazardous
|
||||
? "Yes"
|
||||
: "No"
|
||||
}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Refrigerated"
|
||||
value={
|
||||
Number(booking.bulkReeferQuantity || 0)
|
||||
? `${Number(booking.bulkReeferQuantity).toLocaleString()} ${isPerItem ? "items" : "t"}`
|
||||
: booking.isRefrigerated
|
||||
? "Yes"
|
||||
: "No"
|
||||
}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Equipment return"
|
||||
value={booking.equipmentReturn === "WITH_RETURN" ? "With return" : "Without return"}
|
||||
/>
|
||||
<DetailRow label="Shipping line" value={shippingLineLabel(booking)} />
|
||||
</Box>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tab ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Dedicated cargo breakdown tab: bulk bookings get the full bulk declaration
|
||||
* (commodity, unit of measure, item count vs tonnage, hazardous/reefer
|
||||
* quantities); container bookings get one card per container line with its
|
||||
* per-unit numbers, seals, VGM, GRN and port-arrival status.
|
||||
*/
|
||||
export function CargoTab({ booking }: { booking: BookingDetail }) {
|
||||
const isBulk = booking.freightType === "BULK";
|
||||
const lines = booking.bookingContainers ?? [];
|
||||
const totalUnits = lines.reduce((s, c) => s + Number(c.quantity || 0), 0);
|
||||
const totalVgm = totalVgmTons(booking);
|
||||
const receivedCount = lines
|
||||
.flatMap((l) => l.units ?? [])
|
||||
.filter((u) => u.receivedToPort).length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6" style={{ maxWidth: 980 }}>
|
||||
<SectionCard>
|
||||
<CardTitle>Cargo summary</CardTitle>
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing={10} mt="md">
|
||||
<StatTile
|
||||
icon={isBulk ? <Package size={13} /> : <Container size={13} />}
|
||||
label="Freight type"
|
||||
value={isBulk ? "Bulk" : "Container"}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<BoxIcon size={13} />}
|
||||
label="Commodity"
|
||||
value={commodityLabel(booking)}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<Scale size={13} />}
|
||||
label="Total weight"
|
||||
value={fmtWeight(totalVgm)}
|
||||
/>
|
||||
{isBulk ? (
|
||||
<StatTile
|
||||
icon={<Flame size={13} />}
|
||||
label="Special handling"
|
||||
value={
|
||||
[
|
||||
booking.isHazardous && "Hazardous",
|
||||
booking.isRefrigerated && "Reefer",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || "None"
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<StatTile
|
||||
icon={<Container size={13} />}
|
||||
label="Containers"
|
||||
value={`${totalUnits}`}
|
||||
sub={`${receivedCount} received at port`}
|
||||
/>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</SectionCard>
|
||||
|
||||
{isBulk ? (
|
||||
<BulkCargoCard booking={booking} />
|
||||
) : lines.length > 0 ? (
|
||||
lines.map((line, i) => (
|
||||
<ContainerLineCard key={line.id ?? i} line={line} index={i} />
|
||||
))
|
||||
) : (
|
||||
<SectionCard>
|
||||
<Text fz={13.5} c="#9AA8B5">
|
||||
No container details recorded for this booking yet.
|
||||
</Text>
|
||||
</SectionCard>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -292,7 +292,10 @@ export default function NewBookingPage() {
|
||||
// physically carry the selected cargo/container type. Quantity is NOT part
|
||||
// of this gate — an oversized booking is accepted and gets a partial split
|
||||
// offer later. Only selectable days reach the UI; no capacity counts.
|
||||
const gateContainerTypeIds = useMemo(() => {
|
||||
// Computed per render on purpose — NOT useMemo. react-hook-form mutates the
|
||||
// watched containers array in place on nested edits (containers.0.containerType),
|
||||
// so a reference-based dep list never sees per-line changes.
|
||||
const gateContainerTypeIds = (() => {
|
||||
if (watchedCargoKind !== "container") return [];
|
||||
const groups = referenceData?.containers ?? [];
|
||||
const ids = new Set<string>();
|
||||
@@ -304,7 +307,7 @@ export default function NewBookingPage() {
|
||||
}
|
||||
}
|
||||
return [...ids];
|
||||
}, [watchedCargoKind, watchedContainers, referenceData]);
|
||||
})();
|
||||
const gateCargoTypeId =
|
||||
watchedCargoKind === "bulk" ? watchedCargoTypePath?.[1] : undefined;
|
||||
const gateReady =
|
||||
|
||||
@@ -120,6 +120,7 @@ const PAYMENT_COLORS: Record<string, string> = {
|
||||
// Backend emits the long form on some flows; keep the short alias too.
|
||||
VERIFICATION_IN_PROGRESS: "yellow",
|
||||
PAYMENT_VERIFICATION_IN_PROGRESS: "yellow",
|
||||
PAYMENT_PROCESSING: "yellow",
|
||||
OVERDUE: "red",
|
||||
REFUNDED: "blue",
|
||||
CANCELLED: "gray",
|
||||
@@ -132,6 +133,7 @@ const PAYMENT_LABELS: Record<string, string> = {
|
||||
PNR_GENERATED: "PNR generated",
|
||||
VERIFICATION_IN_PROGRESS: "Verifying",
|
||||
PAYMENT_VERIFICATION_IN_PROGRESS: "Verifying",
|
||||
PAYMENT_PROCESSING: "Payment processing",
|
||||
OVERDUE: "Overdue",
|
||||
REFUNDED: "Refunded",
|
||||
CANCELLED: "Cancelled",
|
||||
|
||||
@@ -1006,9 +1006,11 @@ function ScheduleStep({
|
||||
const isContainer = contract.freightType === "CONTAINER";
|
||||
const containerLines = form.watch("containers");
|
||||
const cargoWeightTons = form.watch("cargoWeightTons");
|
||||
const itemCount = form.watch("itemCount");
|
||||
|
||||
const cargoQuery = useMemo<Freight.AvailableDaysForCargoQuery | null>(() => {
|
||||
// Computed per render on purpose — NOT useMemo. react-hook-form mutates the
|
||||
// watched containers array in place on nested edits (containers.0.quantity),
|
||||
// so a reference-based dep list never sees manual quantity changes.
|
||||
const cargoQuery = ((): Freight.AvailableDaysForCargoQuery | null => {
|
||||
if (!route?.originYardId || !route?.destinationYardId) return null;
|
||||
if (isContainer) {
|
||||
const containers = (containerLines ?? [])
|
||||
@@ -1036,17 +1038,7 @@ function ScheduleStep({
|
||||
?.cargoTypeCode ?? undefined,
|
||||
totalWeightTons: tons,
|
||||
};
|
||||
// Tonnage is the sizing input the day-feasibility endpoint takes, and
|
||||
// PER_ITEM cargo now captures it too — itemCount stays in the deps so the
|
||||
// query still refreshes when only the item count changes.
|
||||
}, [
|
||||
route,
|
||||
isContainer,
|
||||
containerLines,
|
||||
cargoWeightTons,
|
||||
itemCount,
|
||||
contract.pricingBreakdown,
|
||||
]);
|
||||
})();
|
||||
|
||||
const isIntercity = contract.tradeDirection === "DOMESTIC";
|
||||
const { data: availableDays, isLoading } = useQuery({
|
||||
|
||||
@@ -8,10 +8,22 @@ import {
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { CheckCircle2, FileText, Home } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { paymentsService } from "@/services/payments.service";
|
||||
|
||||
export default function PaymentSuccessPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const bookingId = searchParams.get("bookingId");
|
||||
|
||||
// Fire-and-forget ack: payment → processing, invoice → PAYMENT_PROCESSING.
|
||||
// The provider webhook remains the source of truth for the final PAID state.
|
||||
useEffect(() => {
|
||||
if (bookingId) {
|
||||
paymentsService.acknowledgeSuccessRedirect(bookingId).catch(() => {});
|
||||
}
|
||||
}, [bookingId]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
|
||||
@@ -107,6 +107,11 @@ export const paymentsService = {
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** Success-redirect ack: payment → processing, invoice → PAYMENT_PROCESSING. */
|
||||
acknowledgeSuccessRedirect: async (bookingId: string): Promise<void> => {
|
||||
await client.post(P.REDIRECT_SUCCESS(bookingId));
|
||||
},
|
||||
|
||||
checkoutUrl: buildCheckoutUrl,
|
||||
checkoutUrlForInvoice: buildCheckoutUrlForInvoice,
|
||||
};
|
||||
|
||||
@@ -108,6 +108,7 @@ export class CbeBillService {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/* ------------------------------------------------------------------ query */
|
||||
|
||||
async query(dto: CbeQueryRequestDto): Promise<CbeQueryResponseDto> {
|
||||
|
||||
@@ -161,6 +161,8 @@ export enum InvoiceStatus {
|
||||
/** Issued and awaiting payment (alias of PENDING for fee invoices). */
|
||||
Issued = "ISSUED",
|
||||
Pending = "PENDING",
|
||||
/** Customer completed provider checkout (success redirect); awaiting webhook confirmation. */
|
||||
PaymentProcessing = "PAYMENT_PROCESSING",
|
||||
/** Some, but not all, of the balance has been settled. */
|
||||
PartiallyPaid = "PARTIALLY_PAID",
|
||||
Paid = "PAID",
|
||||
|
||||
Reference in New Issue
Block a user