mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 15:18:11 +00:00
Merge pull request #942 from Tria-plc/freight_feature/usermanagement
revert back the clerance payment
This commit is contained in:
@@ -65,8 +65,12 @@ export function computeGlShipmentTotal(
|
||||
(i) =>
|
||||
i.containerSize === line.containerSize &&
|
||||
i.unit === "per_container" &&
|
||||
!i.conditionalOn,
|
||||
) ?? rateFor((i) => i.containerSize === line.containerSize);
|
||||
!i.conditionalOn &&
|
||||
!i.isClearance,
|
||||
) ??
|
||||
rateFor(
|
||||
(i) => i.containerSize === line.containerSize && !i.isClearance,
|
||||
);
|
||||
if (rate) {
|
||||
lines.push({
|
||||
label: rate.label,
|
||||
@@ -123,7 +127,9 @@ export function computeGlShipmentTotal(
|
||||
} else {
|
||||
const qty = q.bulkQuantity;
|
||||
const rate =
|
||||
rateFor((i) => i.unit === "per_ton" || i.unit === "per_item") ?? items[0];
|
||||
rateFor(
|
||||
(i) => (i.unit === "per_ton" || i.unit === "per_item") && !i.isClearance,
|
||||
) ?? items[0];
|
||||
if (rate && qty > 0) {
|
||||
lines.push({
|
||||
label: rate.label,
|
||||
@@ -159,6 +165,36 @@ export function computeGlShipmentTotal(
|
||||
}
|
||||
}
|
||||
|
||||
// Customs clearance service fee — billed on the booking invoice with the
|
||||
// freight. Container fees estimate per size (per box, or per wagon: two 20ft
|
||||
// share one); bulk per-ton scales by tonnage. Bulk per-wagon fees depend on
|
||||
// the wagon capacity the train stocks — shown at real pricing, not estimated.
|
||||
for (const cl of items.filter((i) => i.isClearance)) {
|
||||
let qty = 0;
|
||||
if (q.isContainer) {
|
||||
const boxes = q.containers
|
||||
.filter((c) => c.containerSize === cl.containerSize)
|
||||
.reduce((s, c) => s + Number(c.quantity || 0), 0);
|
||||
qty =
|
||||
cl.unit === "per_wagon"
|
||||
? Math.ceil(boxes * (cl.containerSize === "40ft" ? 1 : 0.5))
|
||||
: boxes;
|
||||
} else if (cl.unit === "per_ton") {
|
||||
qty = q.bulkQuantity;
|
||||
} else if (cl.unit === "flat") {
|
||||
qty = 1;
|
||||
}
|
||||
if (qty > 0) {
|
||||
lines.push({
|
||||
label: cl.label,
|
||||
unitPrice: cl.unitPrice,
|
||||
unit: cl.unit,
|
||||
quantity: qty,
|
||||
amount: cl.unitPrice * qty,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const total = lines.reduce((s, l) => s + l.amount, 0);
|
||||
return { currency, lines, total };
|
||||
}
|
||||
@@ -167,6 +203,7 @@ export function computeGlShipmentTotal(
|
||||
export function formatRateUnit(unit: Freight.ContractRateUnit | string): string {
|
||||
const map: Record<string, string> = {
|
||||
per_container: "container",
|
||||
per_wagon: "wagon",
|
||||
per_ton: "ton",
|
||||
per_item: "item",
|
||||
per_km: "km",
|
||||
|
||||
@@ -209,6 +209,12 @@ const RuleEngineFormDialog = ({
|
||||
next.containerTypeId = "";
|
||||
next.cargoTypeId = "";
|
||||
}
|
||||
// Cargo kind (customs / lashing) decides both the container-type scope
|
||||
// and the legal units (container → per box/wagon, bulk → per ton/wagon).
|
||||
if (name === "cargoKind") {
|
||||
next.containerTypeId = "";
|
||||
next.rateUnit = "";
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -286,7 +286,6 @@ export const BOOKING_LIST_TABS = [
|
||||
key: "clearance",
|
||||
label: "Clearance",
|
||||
statuses: [
|
||||
"AWAITING_CLEARANCE_PAYMENT",
|
||||
"AWAITING_DOCUMENTS",
|
||||
"DOCUMENTS_UNDER_REVIEW",
|
||||
"CLEARANCE_READY",
|
||||
|
||||
@@ -51,10 +51,6 @@ export const CONTRACT_STATUS_STYLES: Record<string, StatusStyle> = {
|
||||
label: "Active",
|
||||
color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]",
|
||||
},
|
||||
AWAITING_CLEARANCE_PAYMENT: {
|
||||
label: "Clearance Fee Due",
|
||||
color: "bg-orange-50 text-orange-700 border-orange-200",
|
||||
},
|
||||
AWAITING_CLEARANCE_DOCUMENTS: {
|
||||
label: "Awaiting Documents",
|
||||
color: "bg-amber-50 text-amber-700 border-amber-200",
|
||||
@@ -122,7 +118,6 @@ export const CONTRACT_STATUS_COLOR: Record<string, string> = {
|
||||
SIGNED_CUSTOMER: "cyan",
|
||||
FULLY_EXECUTED: "indigo",
|
||||
CONTRACT_ACTIVE: "edr-green",
|
||||
AWAITING_CLEARANCE_PAYMENT: "orange",
|
||||
AWAITING_CLEARANCE_DOCUMENTS: "yellow",
|
||||
CLEARANCE_UNDER_REVIEW: "yellow",
|
||||
CLEARANCE_READY_FOR_BOOKING: "edr-green",
|
||||
@@ -212,13 +207,6 @@ export const CONTRACT_STATUS_META: Record<string, StatusMeta> = {
|
||||
color: "text-[color:var(--freight-brand)]",
|
||||
stage: 3,
|
||||
},
|
||||
AWAITING_CLEARANCE_PAYMENT: {
|
||||
title: "Clearance Fee Due",
|
||||
description:
|
||||
"Customer must pay the prepaid clearance service fee before uploading documents.",
|
||||
color: "text-orange-600",
|
||||
stage: 3,
|
||||
},
|
||||
AWAITING_CLEARANCE_DOCUMENTS: {
|
||||
title: "Awaiting Documents",
|
||||
description: "Customer is uploading pre-booking clearance documents.",
|
||||
|
||||
@@ -25,6 +25,8 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
tradeDirection: "Direction",
|
||||
containerTypeId: "Container type",
|
||||
cargoTypeId: "Cargo type",
|
||||
originYardId: "Origin yard",
|
||||
destinationYardId: "Destination yard",
|
||||
};
|
||||
|
||||
const fmtDateTime = (iso: string) =>
|
||||
@@ -36,12 +38,20 @@ const fmtDateTime = (iso: string) =>
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const fmtValue = (field: string, value: unknown): string => {
|
||||
const fmtValue = (
|
||||
field: string,
|
||||
value: unknown,
|
||||
labels?: Record<string, string>,
|
||||
): string => {
|
||||
if (value === null || value === undefined || value === "") return "—";
|
||||
if (field === "rateValue") {
|
||||
const num = Number(value);
|
||||
return Number.isNaN(num) ? String(value) : num.toLocaleString();
|
||||
}
|
||||
// Yard ids are unreadable — an approver decides on the route, not a UUID.
|
||||
if (field === "originYardId" || field === "destinationYardId") {
|
||||
return labels?.[String(value)] ?? String(value);
|
||||
}
|
||||
return String(value).replace(/_/g, " ");
|
||||
};
|
||||
|
||||
@@ -77,6 +87,8 @@ interface RateApprovalsSectionProps {
|
||||
canDecide: boolean;
|
||||
approve: Decide;
|
||||
reject: Decide;
|
||||
/** yardId → label, so a re-routed rate reads as yards, not UUIDs. */
|
||||
yardLabels?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,6 +101,7 @@ const RateApprovalsSection = ({
|
||||
canDecide,
|
||||
approve,
|
||||
reject,
|
||||
yardLabels,
|
||||
}: RateApprovalsSectionProps) => {
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [notes, setNotes] = useState<Record<string, string>>({});
|
||||
@@ -212,11 +225,11 @@ const RateApprovalsSection = ({
|
||||
{FIELD_LABELS[field] ?? field}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" td="line-through">
|
||||
{fmtValue(field, r.previousValues[field])}
|
||||
{fmtValue(field, r.previousValues[field], yardLabels)}
|
||||
</Text>
|
||||
<ArrowRight size={13} />
|
||||
<Text size="sm" fw={600}>
|
||||
{fmtValue(field, r.payload[field])}
|
||||
{fmtValue(field, r.payload[field], yardLabels)}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
|
||||
@@ -269,6 +269,10 @@ const RuleEngineResourcePage = () => {
|
||||
);
|
||||
const { data: yardOptions, isLoading: yardOptionsLoading } =
|
||||
useYardOptions(usesYardField);
|
||||
const yardLabelById = useMemo(
|
||||
() => Object.fromEntries((yardOptions ?? []).map((y) => [y.value, y.label])),
|
||||
[yardOptions],
|
||||
);
|
||||
const usesApprovalRoleField = Boolean(
|
||||
config?.formFields.some(
|
||||
(f) => f.name === "requiredRole" || f.name === "blocksRole",
|
||||
@@ -661,6 +665,7 @@ const RuleEngineResourcePage = () => {
|
||||
canDecide={canApproveRates}
|
||||
approve={rateChangeWorkflow.approve}
|
||||
reject={rateChangeWorkflow.reject}
|
||||
yardLabels={yardLabelById}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -171,12 +171,11 @@ const RATE_TRIGGERS = [
|
||||
value: "WITH_RETURN",
|
||||
},
|
||||
{ label: "Shipping line mapped", value: "SHIPPING_LINE" },
|
||||
{ label: "Consolidation", value: "CONSOLIDATION" },
|
||||
{ label: "Lashing (flat, per booking)", value: "LASHING" },
|
||||
{ label: "Penalty", value: "CONSOLIDATION" },
|
||||
{ label: "Lashing (per container type / bulk)", value: "LASHING" },
|
||||
{ label: "Cancellation", value: "CANCELLATION" },
|
||||
{ label: "Demurrage", value: "DEMURRAGE" },
|
||||
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
|
||||
{ label: "Customs clearance service fee (prepaid)", value: "CUSTOMS_CLEARANCE" },
|
||||
{ label: "Customs clearance service fee (billed with the booking)", value: "CUSTOMS_CLEARANCE" },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -211,7 +210,11 @@ const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value
|
||||
* bill per container, bulk per ton, overweight always per excess ton, etc. Kept
|
||||
* in sync with apps/edr-freight-api/.../entities/rate-unit.util.ts.
|
||||
*/
|
||||
const allowedRateUnits = (appliesTo: string, trigger: string): string[] => {
|
||||
const allowedRateUnits = (
|
||||
appliesTo: string,
|
||||
trigger: string,
|
||||
cargoKind = "",
|
||||
): string[] => {
|
||||
if (appliesTo === "OTHER") {
|
||||
switch (trigger) {
|
||||
case "OVERWEIGHT":
|
||||
@@ -221,16 +224,16 @@ const allowedRateUnits = (appliesTo: string, trigger: string): string[] => {
|
||||
case "DEMURRAGE":
|
||||
return ["PER_CONTAINER", "PER_TON"];
|
||||
case "WITH_RETURN":
|
||||
// Container-only service — bills per returned container.
|
||||
return ["PER_CONTAINER", "FLAT"];
|
||||
// Container-only service — per returned container, per wagon, or flat.
|
||||
return ["PER_CONTAINER", "PER_WAGON", "FLAT"];
|
||||
case "CANCELLATION":
|
||||
return ["FLAT", "PER_INVOICE"];
|
||||
case "CUSTOMS_CLEARANCE":
|
||||
// Flat per clearance (ONE_TIME) / per shipment request (GENERAL).
|
||||
return ["FLAT"];
|
||||
case "LASHING":
|
||||
// Flat cargo-securing fee, billed once per booking.
|
||||
return ["FLAT"];
|
||||
// Per cargo kind: container fees per box/wagon, bulk per ton/wagon.
|
||||
return cargoKind === "BULK"
|
||||
? ["PER_TON", "PER_WAGON"]
|
||||
: ["PER_CONTAINER", "PER_WAGON"];
|
||||
case "CONSOLIDATION":
|
||||
case "SHIPPING_LINE":
|
||||
case "PIL_EXTRA_FEE":
|
||||
@@ -258,7 +261,11 @@ const rateUnitOptions = (values: Record<string, unknown>) => {
|
||||
const appliesTo = String(values.appliesTo ?? "");
|
||||
const trigger = appliesTo === "OTHER" ? String(values.trigger ?? "") : "ALWAYS";
|
||||
if (!appliesTo) return [];
|
||||
return allowedRateUnits(appliesTo, trigger).map(unitOption);
|
||||
return allowedRateUnits(
|
||||
appliesTo,
|
||||
trigger,
|
||||
String(values.cargoKind ?? ""),
|
||||
).map(unitOption);
|
||||
};
|
||||
|
||||
const CURRENCIES = [
|
||||
@@ -659,7 +666,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
filters: {
|
||||
appliesTo: "OTHER",
|
||||
trigger:
|
||||
"HAZARDOUS,OVERWEIGHT,REEFER,SHIPPING_LINE,CONSOLIDATION,LASHING,CANCELLATION,DEMURRAGE,PIL_EXTRA_FEE",
|
||||
"HAZARDOUS,OVERWEIGHT,REEFER,SHIPPING_LINE,CONSOLIDATION,LASHING,CANCELLATION,PIL_EXTRA_FEE",
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -717,6 +724,37 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
(String(v.appliesTo ?? "") === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(v.trigger ?? ""))),
|
||||
},
|
||||
// ── Cargo kind — customs clearance and lashing are priced separately
|
||||
// for containers (one rate per container type) and bulk ────────────────
|
||||
{
|
||||
name: "cargoKind",
|
||||
label: "Cargo kind",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: INTERCITY_KINDS,
|
||||
placeholder: "Is this fee for containers or bulk?",
|
||||
description:
|
||||
"Container fees bill per box or wagon (one rate per container type); bulk fees bill per ton or wagon.",
|
||||
showIf: (v) =>
|
||||
v.appliesTo === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "LASHING"].includes(String(v.trigger ?? "")),
|
||||
// Not a stored column: a container fee carries its containerTypeId, a
|
||||
// bulk fee carries none.
|
||||
getInitialValue: (record) =>
|
||||
record.containerTypeId ? "CONTAINER" : "BULK",
|
||||
},
|
||||
// ── Container type — a container fee names the type it covers ─────────
|
||||
{
|
||||
name: "containerTypeId",
|
||||
label: "Container type",
|
||||
type: "select",
|
||||
required: true,
|
||||
placeholder: "Which container type this fee covers",
|
||||
showIf: (v) =>
|
||||
v.appliesTo === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "LASHING"].includes(String(v.trigger ?? "")) &&
|
||||
v.cargoKind === "CONTAINER",
|
||||
},
|
||||
// ── Cargo kind — Intercity only (import/export get it from appliesTo) ─
|
||||
{
|
||||
name: "intercityKind",
|
||||
|
||||
@@ -28,7 +28,6 @@ export const BOOKING_STATUSES = [
|
||||
"CONTRACT_ACTIVE",
|
||||
"CONTRACT_CLOSED",
|
||||
// Post counter-sign document-clearance gate.
|
||||
"AWAITING_CLEARANCE_PAYMENT",
|
||||
"AWAITING_DOCUMENTS",
|
||||
"DOCUMENTS_UNDER_REVIEW",
|
||||
"CLEARANCE_READY",
|
||||
|
||||
@@ -14,7 +14,6 @@ import { useNavigate } from "react-router-dom";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { PayClearanceFeeButton } from "@/pages/bookings/payments/PayClearanceFeeButton";
|
||||
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
|
||||
import { api } from "@/services/api";
|
||||
import { ContractClearanceAction } from "./ContractClearanceAction";
|
||||
@@ -70,17 +69,6 @@ export function ContractCustomerAction({
|
||||
);
|
||||
}
|
||||
|
||||
if (action.type === "pay-clearance") {
|
||||
return (
|
||||
<PayClearanceFeeButton
|
||||
sourceId={action.contractId}
|
||||
currency={contract.paymentCurrency}
|
||||
label={action.label}
|
||||
size={size}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (action.type === "initiate") {
|
||||
return (
|
||||
<InitiateBookingButton
|
||||
|
||||
@@ -80,14 +80,6 @@ export type ContractCustomerAction =
|
||||
label: string;
|
||||
primary: boolean;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
| {
|
||||
/** Prepaid customs clearance service fee (contract-level, ONE_TIME Path B). */
|
||||
type: "pay-clearance";
|
||||
contractId: string;
|
||||
label: string;
|
||||
primary: boolean;
|
||||
icon: LucideIcon;
|
||||
};
|
||||
|
||||
function findPayableBookingForContract(
|
||||
@@ -145,17 +137,6 @@ export function deriveContractCustomerAction(
|
||||
};
|
||||
}
|
||||
|
||||
// Prepaid clearance service fee gate — must settle before document upload.
|
||||
if (contract.status === "AWAITING_CLEARANCE_PAYMENT") {
|
||||
return {
|
||||
type: "pay-clearance",
|
||||
contractId: id,
|
||||
label: "Pay clearance fee",
|
||||
primary: true,
|
||||
icon: CreditCard,
|
||||
};
|
||||
}
|
||||
|
||||
const payable = findPayableBookingForContract(id, bookings);
|
||||
if (payable) {
|
||||
return {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { contractNeedsClearanceAction } from "@/components/customer-actions/deri
|
||||
export interface ActionItem {
|
||||
id: string;
|
||||
/** What the customer must do — drives the icon, label and modal. */
|
||||
kind: "clearance" | "duty" | "sign" | "book" | "pay" | "clearance-fee";
|
||||
kind: "clearance" | "duty" | "sign" | "book" | "pay";
|
||||
/** The contract/booking reference for display. */
|
||||
reference: string;
|
||||
/** Short human description of the action. */
|
||||
@@ -40,18 +40,6 @@ export function deriveActionItems(
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Prepaid clearance service fee (Path B) — blocks the document step.
|
||||
if (c.status === "AWAITING_CLEARANCE_PAYMENT") {
|
||||
items.push({
|
||||
id: `clearance-fee-${c.id}`,
|
||||
kind: "clearance-fee",
|
||||
reference: c.reference,
|
||||
description: "Clearance service fee due — pay to unlock document upload",
|
||||
targetId: c.id,
|
||||
urgent: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const clr = contractNeedsClearanceAction(c);
|
||||
if (clr.show) {
|
||||
items.push({
|
||||
@@ -82,19 +70,6 @@ export function deriveActionItems(
|
||||
}
|
||||
|
||||
for (const b of bookings) {
|
||||
// Per-shipment clearance service fee (GENERAL + customs shipment request).
|
||||
if (b.status === "AWAITING_CLEARANCE_PAYMENT") {
|
||||
items.push({
|
||||
id: `clearance-fee-${b.id}`,
|
||||
kind: "clearance-fee",
|
||||
reference: b.reference,
|
||||
description:
|
||||
"Clearance service fee due for this shipment — pay to unlock document upload",
|
||||
targetId: b.id,
|
||||
urgent: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const isGeneral = b.bookingType === "GENERAL_CONTRACT";
|
||||
const canPay =
|
||||
b.paymentStatus !== "PAID" &&
|
||||
|
||||
@@ -39,7 +39,6 @@ const KIND_META: Record<
|
||||
sign: { icon: FileSignature, label: "Sign", color: "blue" },
|
||||
book: { icon: PackagePlus, label: "Book", color: "violet" },
|
||||
pay: { icon: CreditCard, label: "Payment", color: "orange" },
|
||||
"clearance-fee": { icon: CreditCard, label: "Clearance fee", color: "orange" },
|
||||
};
|
||||
|
||||
export interface ActionNeededSectionProps {
|
||||
@@ -138,14 +137,10 @@ export function ActionNeededSection({
|
||||
|
||||
// Billing is invoice-centric — resolve the booking's currently payable
|
||||
// invoice before paying it (mirrors ReadonlyBookingView).
|
||||
// A "pay" item settles the booking invoice; a "clearance-fee" item settles the
|
||||
// prepaid clearance-fee invoice (source `clearance`, keyed by contract or
|
||||
// booking id depending on where the gate sits).
|
||||
const payItemSource = payItem?.kind === "clearance-fee" ? "clearance" : "booking";
|
||||
const { data: payItemInvoices = [] } = useQuery({
|
||||
queryKey: [`${payItemSource}-invoices`, payItem?.targetId],
|
||||
queryKey: ["booking-invoices", payItem?.targetId],
|
||||
queryFn: () =>
|
||||
invoicesService.listForSource(payItemSource, payItem!.targetId),
|
||||
invoicesService.listForSource("booking", payItem!.targetId),
|
||||
enabled: payItem !== null,
|
||||
});
|
||||
const payableInvoiceId = payItemInvoices.find((inv) =>
|
||||
@@ -188,7 +183,6 @@ export function ActionNeededSection({
|
||||
navigate(`/contracts/${item.targetId}`);
|
||||
break;
|
||||
case "pay":
|
||||
case "clearance-fee":
|
||||
setPayItem(item);
|
||||
break;
|
||||
case "sign":
|
||||
@@ -284,9 +278,7 @@ export function ActionNeededSection({
|
||||
>
|
||||
{item.kind === "pay"
|
||||
? "Pay now"
|
||||
: item.kind === "clearance-fee"
|
||||
? "Pay clearance fee"
|
||||
: item.kind === "duty"
|
||||
: item.kind === "duty"
|
||||
? "Pay duty & upload slip"
|
||||
: item.kind === "sign"
|
||||
? "Sign"
|
||||
|
||||
@@ -165,19 +165,6 @@ export const STATUS_CONFIG: Record<string, StageConfig> = {
|
||||
badgeDot: "edr-green.5",
|
||||
action: { label: "View", kind: "outline" },
|
||||
},
|
||||
AWAITING_CLEARANCE_PAYMENT: {
|
||||
stage: 3,
|
||||
icon: Wallet,
|
||||
iconColor: "edr-amber-text",
|
||||
tile: "edr-amber-soft",
|
||||
hint: "Clearance service fee due · pay to unlock document upload",
|
||||
step: "edr-accent",
|
||||
badgeLabel: "Clearance fee due",
|
||||
badgeBg: "edr-amber-soft",
|
||||
badgeText: "edr-amber-text",
|
||||
badgeDot: "edr-accent",
|
||||
action: { label: "Pay clearance fee", kind: "amber", icon: ArrowRight },
|
||||
},
|
||||
AWAITING_DOCUMENTS: {
|
||||
stage: 3,
|
||||
icon: FileUp,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Group, Paper, Tabs, Text } from "@mantine/core";
|
||||
import { Group, Tabs } from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { CreditCard, FileText, LayoutGrid } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
@@ -12,7 +12,6 @@ import { isPayable } from "@/pages/billing/invoice-ui";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
|
||||
import { PayClearanceFeeButton } from "../payments/PayClearanceFeeButton";
|
||||
import { ActivityCard } from "./components/ActivityCard";
|
||||
import { ClearanceCard } from "./components/ClearanceCard";
|
||||
import { DocumentsTab } from "./components/DocumentsTab";
|
||||
@@ -143,8 +142,6 @@ export function ReadonlyBookingView({
|
||||
const isCustoms = Boolean(booking.customsClearingEnabled);
|
||||
const canSelfRebook = !isCustoms;
|
||||
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
|
||||
// Prepaid clearance service fee gate — document upload stays locked until paid.
|
||||
const isAwaitingClearanceFee = status === "AWAITING_CLEARANCE_PAYMENT";
|
||||
const isClearance = [
|
||||
"AWAITING_DOCUMENTS",
|
||||
"DOCUMENTS_UNDER_REVIEW",
|
||||
@@ -243,28 +240,6 @@ export function ReadonlyBookingView({
|
||||
<div className="flex flex-col gap-6">
|
||||
<ContractCard booking={booking} />
|
||||
|
||||
{isAwaitingClearanceFee && (
|
||||
<Paper withBorder radius="lg" p="lg" style={{ borderColor: "#FDE68A", background: "#FFFBEB" }}>
|
||||
<Group justify="space-between" align="center" wrap="wrap" gap="md">
|
||||
<div>
|
||||
<Text fw={700} fz={15} c="#92400E">
|
||||
Customs clearance service fee due
|
||||
</Text>
|
||||
<Text fz={13} c="#B45309" mt={4}>
|
||||
Pay the clearance service fee to unlock the clearance
|
||||
document upload. Global Logistics starts working on your
|
||||
shipment once the fee is settled.
|
||||
</Text>
|
||||
</div>
|
||||
<PayClearanceFeeButton
|
||||
sourceId={booking.id}
|
||||
currency={booking.paymentCurrency}
|
||||
size="md"
|
||||
/>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{isClearance && <ClearanceCard booking={booking} />}
|
||||
|
||||
<BodyGrid
|
||||
|
||||
@@ -51,17 +51,10 @@ export function buildJourneySteps(
|
||||
if (isCustoms && milestones.length > 0) {
|
||||
const sorted = [...milestones].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
const firstPendingId = sorted.find((m) => m.status === "PENDING")?.id;
|
||||
const feeActive = status === "AWAITING_CLEARANCE_PAYMENT";
|
||||
const delivered = ["COMPLETED", "DELIVERED"].includes(status);
|
||||
|
||||
const steps: JourneyStep[] = [
|
||||
{ key: "booked", label: "Booking initiated", state: "done" },
|
||||
{
|
||||
key: "fee",
|
||||
label: "Clearance fee paid",
|
||||
state: feeActive ? "active" : "done",
|
||||
owner: "CUST",
|
||||
},
|
||||
...sorted.map<JourneyStep>((m) => ({
|
||||
key: m.id,
|
||||
label: m.milestoneLabel,
|
||||
@@ -71,7 +64,7 @@ export function buildJourneySteps(
|
||||
? "done"
|
||||
: m.status === "SKIPPED"
|
||||
? "skipped"
|
||||
: !feeActive && m.id === firstPendingId
|
||||
: m.id === firstPendingId
|
||||
? "active"
|
||||
: "idle",
|
||||
})),
|
||||
@@ -79,7 +72,7 @@ export function buildJourneySteps(
|
||||
];
|
||||
// Every known milestone is done but the booking hasn't closed yet — the
|
||||
// delivery step is what's in progress.
|
||||
if (!feeActive && !firstPendingId && !delivered) {
|
||||
if (!firstPendingId && !delivered) {
|
||||
steps[steps.length - 1].state = "active";
|
||||
}
|
||||
return steps;
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
CreditCard,
|
||||
PackagePlus,
|
||||
PencilLine,
|
||||
Upload,
|
||||
@@ -12,7 +11,6 @@ import { useNavigate } from "react-router-dom";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { PayClearanceFeeButton } from "@/pages/bookings/payments/PayClearanceFeeButton";
|
||||
import { ResubmitBookingModal } from "@/pages/bookings/resubmit/ResubmitBookingModal";
|
||||
|
||||
import { BookingActionModal } from "./BookingActionModal";
|
||||
@@ -25,7 +23,6 @@ const ICON_BY_KIND: Record<
|
||||
BookingActionKind,
|
||||
typeof Upload
|
||||
> = {
|
||||
PAY_CLEARANCE: CreditCard,
|
||||
UPLOAD_DOCUMENTS: Upload,
|
||||
FIX_DOCUMENTS: AlertCircle,
|
||||
SCHEDULE_OPERATION: ArrowRight,
|
||||
@@ -59,19 +56,6 @@ export function BookingActionButton({
|
||||
|
||||
if (!isChangesRequested && !action) return null;
|
||||
|
||||
// The prepaid clearance service fee has its own payment flow (method modal +
|
||||
// provider redirect) — delegate to the self-contained pay button.
|
||||
if (action?.kind === "PAY_CLEARANCE") {
|
||||
return (
|
||||
<PayClearanceFeeButton
|
||||
sourceId={booking.id}
|
||||
currency={booking.paymentCurrency}
|
||||
label={action.label}
|
||||
size={size}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = action ? ICON_BY_KIND[action.kind] : PencilLine;
|
||||
const label = action ? action.label : "Update & resubmit";
|
||||
// BOOK navigates to the booking form (cargo + day + window check) — the
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { Freight } from "@edr/types";
|
||||
* to operation.
|
||||
*/
|
||||
export type BookingActionKind =
|
||||
| "PAY_CLEARANCE" // AWAITING_CLEARANCE_PAYMENT — pay the prepaid clearance service fee
|
||||
| "UPLOAD_DOCUMENTS" // AWAITING_DOCUMENTS — upload the required clearance docs
|
||||
| "FIX_DOCUMENTS" // DOCUMENTS_UNDER_REVIEW — some docs queried, re-upload them
|
||||
| "SCHEDULE_OPERATION" // CLEARANCE_READY (legacy with cargo) — pick a day and proceed
|
||||
@@ -24,11 +23,6 @@ export interface BookingNextAction {
|
||||
}
|
||||
|
||||
const ACTION_BY_STATUS: Record<string, BookingNextAction> = {
|
||||
AWAITING_CLEARANCE_PAYMENT: {
|
||||
kind: "PAY_CLEARANCE",
|
||||
label: "Pay clearance fee",
|
||||
title: "Pay the clearance service fee",
|
||||
},
|
||||
AWAITING_DOCUMENTS: {
|
||||
kind: "UPLOAD_DOCUMENTS",
|
||||
label: "Upload documents",
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import { Button, type ButtonProps } from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { CreditCard } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper";
|
||||
import { isPayable } from "@/pages/billing/invoice-ui";
|
||||
import { api } from "@/services/api";
|
||||
import { invoicesService } from "@/services/invoices.service";
|
||||
import {
|
||||
paymentsService,
|
||||
type PaymentMethod,
|
||||
} from "@/services/payments.service";
|
||||
import { PaymentMethodModal } from "../BookingDetailPage/components/PaymentMethodModal";
|
||||
|
||||
/**
|
||||
* Payment flow for the prepaid customs clearance service fee. The fee is its
|
||||
* own `clearance`-source invoice — sourceId is the contract id (ONE_TIME,
|
||||
* contract status AWAITING_CLEARANCE_PAYMENT) or the booking id (GENERAL
|
||||
* shipment request, booking status AWAITING_CLEARANCE_PAYMENT). Paying it
|
||||
* unlocks the clearance document upload; same modal + provider redirect as
|
||||
* booking payment.
|
||||
*/
|
||||
export function useClearanceFeePayment(sourceId: string) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const { data: invoices = [] } = useQuery({
|
||||
queryKey: ["clearance-invoices", sourceId],
|
||||
queryFn: () => invoicesService.listForSource("clearance", sourceId),
|
||||
enabled: Boolean(sourceId),
|
||||
});
|
||||
const payableInvoice = invoices.find((inv) => isPayable(inv.status)) ?? null;
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (method: PaymentMethod) => {
|
||||
if (!payableInvoice) {
|
||||
throw new Error(
|
||||
"No payable clearance-fee invoice found yet. Please refresh or contact support.",
|
||||
);
|
||||
}
|
||||
return api.invoices.pay.call({
|
||||
id: payableInvoice.id,
|
||||
payload: { method, platform: "web" },
|
||||
});
|
||||
},
|
||||
onSuccess: (data, method) => {
|
||||
const redirectUrl =
|
||||
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
|
||||
? data.clientAction.url
|
||||
: paymentsService.checkoutUrlForInvoice({
|
||||
invoiceId: payableInvoice!.id,
|
||||
method,
|
||||
});
|
||||
window.location.href = redirectUrl;
|
||||
},
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
if (!mutation.isPending) {
|
||||
setModalOpen(false);
|
||||
mutation.reset();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
invoice: payableInvoice,
|
||||
modalOpen,
|
||||
open: () => setModalOpen(true),
|
||||
close,
|
||||
processing: mutation.isPending,
|
||||
error: mutation.isError
|
||||
? mutation.error instanceof Error
|
||||
? mutation.error.message
|
||||
: "Could not start payment. Please try again."
|
||||
: null,
|
||||
confirm: (method: PaymentMethod) => mutation.mutate(method),
|
||||
};
|
||||
}
|
||||
|
||||
interface PayClearanceFeeButtonProps {
|
||||
/** Contract id (ONE_TIME) or booking id (GENERAL shipment) the fee bills. */
|
||||
sourceId: string;
|
||||
/** Fallback currency while the invoice is loading. */
|
||||
currency?: string;
|
||||
label?: string;
|
||||
size?: ButtonProps["size"];
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
/** Self-contained "Pay clearance fee" action — modal in place, no navigation. */
|
||||
export function PayClearanceFeeButton({
|
||||
sourceId,
|
||||
currency,
|
||||
label = "Pay clearance fee",
|
||||
size = "xs",
|
||||
fullWidth,
|
||||
}: PayClearanceFeeButtonProps) {
|
||||
const pay = useClearanceFeePayment(sourceId);
|
||||
|
||||
return (
|
||||
<ModalSafeWrapper>
|
||||
<Button
|
||||
size={size}
|
||||
radius="md"
|
||||
fw={700}
|
||||
fz={13}
|
||||
color="edr-green"
|
||||
fullWidth={fullWidth}
|
||||
leftSection={<CreditCard size={14} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
pay.open();
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
|
||||
<PaymentMethodModal
|
||||
opened={pay.modalOpen}
|
||||
onClose={pay.close}
|
||||
amountLabel={
|
||||
pay.invoice
|
||||
? `${Number(
|
||||
pay.invoice.balanceAmount ?? pay.invoice.totalAmount,
|
||||
).toLocaleString()} ${pay.invoice.currency}`
|
||||
: undefined
|
||||
}
|
||||
currency={pay.invoice?.currency ?? currency}
|
||||
processing={pay.processing}
|
||||
error={pay.error}
|
||||
onConfirm={pay.confirm}
|
||||
/>
|
||||
</ModalSafeWrapper>
|
||||
);
|
||||
}
|
||||
@@ -72,7 +72,6 @@ import { ContractClearancePanel } from "./ContractClearancePanel";
|
||||
import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner";
|
||||
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
||||
import { InitiateBookingButton } from "@/components/customer-actions/ContractCustomerAction";
|
||||
import { PayClearanceFeeButton } from "@/pages/bookings/payments/PayClearanceFeeButton";
|
||||
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
||||
import { getContractBookingAction } from "./contract-booking-action";
|
||||
import { closedWindowMessage, hasOpenWindow } from "./booking-window";
|
||||
@@ -438,9 +437,6 @@ export default function ContractDetailPage() {
|
||||
// clearance is finalized.
|
||||
const canUploadClearance =
|
||||
CLEARANCE_UPLOAD_STATUSES.includes(contract.status) && !clearanceFinalized;
|
||||
// Prepaid clearance service fee gate (Path B) — the document step stays
|
||||
// locked until the fee invoice settles.
|
||||
const awaitingClearanceFee = contract.status === "AWAITING_CLEARANCE_PAYMENT";
|
||||
|
||||
return (
|
||||
<Box style={{ padding: "28px 32px 40px" }}>
|
||||
@@ -574,13 +570,6 @@ export default function ContractDetailPage() {
|
||||
Global Logistics is creating your booking
|
||||
</Badge>
|
||||
)}
|
||||
{awaitingClearanceFee && (
|
||||
<PayClearanceFeeButton
|
||||
sourceId={contract.id}
|
||||
currency={contract.paymentCurrency}
|
||||
size="md"
|
||||
/>
|
||||
)}
|
||||
{canUploadClearance && (
|
||||
<Button
|
||||
color="edr-green"
|
||||
|
||||
@@ -125,10 +125,6 @@ export const CONTRACT_STATUS_CONFIG: Record<
|
||||
FULLY_EXECUTED: { label: "Fully Executed", ...TONE.success },
|
||||
CONTRACT_ACTIVE: { label: "Active", ...TONE.success },
|
||||
// ── Path B pre-booking clearance (contract-level) ──
|
||||
AWAITING_CLEARANCE_PAYMENT: {
|
||||
label: "Clearance Fee Due",
|
||||
...TONE.warning,
|
||||
},
|
||||
AWAITING_CLEARANCE_DOCUMENTS: {
|
||||
label: "Upload Clearance Docs",
|
||||
...TONE.warning,
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Freight } from "@edr/types";
|
||||
export function formatRateUnit(unit: Freight.ContractRateUnit | string): string {
|
||||
const map: Record<string, string> = {
|
||||
per_container: "container",
|
||||
per_wagon: "wagon",
|
||||
per_ton: "ton",
|
||||
per_item: "item",
|
||||
per_km: "km",
|
||||
|
||||
@@ -48,8 +48,12 @@ export function computeShipmentTotal(
|
||||
(i) =>
|
||||
i.containerSize === line.containerSize &&
|
||||
i.unit === "per_container" &&
|
||||
!i.conditionalOn,
|
||||
) ?? rateFor((i) => i.containerSize === line.containerSize);
|
||||
!i.conditionalOn &&
|
||||
!i.isClearance,
|
||||
) ??
|
||||
rateFor(
|
||||
(i) => i.containerSize === line.containerSize && !i.isClearance,
|
||||
);
|
||||
if (rate) {
|
||||
lines.push({
|
||||
label: rate.label,
|
||||
@@ -106,7 +110,9 @@ export function computeShipmentTotal(
|
||||
} else {
|
||||
const qty = Number(values.cargoWeightTons || values.itemCount || 0);
|
||||
const rate =
|
||||
rateFor((i) => i.unit === "per_ton" || i.unit === "per_item") ?? items[0];
|
||||
rateFor(
|
||||
(i) => (i.unit === "per_ton" || i.unit === "per_item") && !i.isClearance,
|
||||
) ?? items[0];
|
||||
if (rate && qty > 0) {
|
||||
lines.push({
|
||||
label: rate.label,
|
||||
@@ -144,6 +150,36 @@ export function computeShipmentTotal(
|
||||
}
|
||||
}
|
||||
|
||||
// Customs clearance service fee — billed on the booking invoice with the
|
||||
// freight. Container fees estimate per size (per box, or per wagon: two 20ft
|
||||
// share one); bulk per-ton scales by tonnage. Bulk per-wagon fees depend on
|
||||
// the wagon capacity the train stocks — shown at real pricing, not estimated.
|
||||
for (const cl of items.filter((i) => i.isClearance)) {
|
||||
let qty = 0;
|
||||
if (isContainer) {
|
||||
const boxes = (values.containers ?? [])
|
||||
.filter((c) => c.containerSize === cl.containerSize)
|
||||
.reduce((s, c) => s + Number(c.quantity || 0), 0);
|
||||
qty =
|
||||
cl.unit === "per_wagon"
|
||||
? Math.ceil(boxes * (cl.containerSize === "40ft" ? 1 : 0.5))
|
||||
: boxes;
|
||||
} else if (cl.unit === "per_ton") {
|
||||
qty = Number(values.cargoWeightTons || 0);
|
||||
} else if (cl.unit === "flat") {
|
||||
qty = 1;
|
||||
}
|
||||
if (qty > 0) {
|
||||
lines.push({
|
||||
label: cl.label,
|
||||
unitPrice: cl.unitPrice,
|
||||
unit: cl.unit,
|
||||
quantity: qty,
|
||||
amount: cl.unitPrice * qty,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const total = lines.reduce((s, l) => s + l.amount, 0);
|
||||
return { currency, lines, total };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user