[200~feat: add CustomsPaymentsCard and PaymentsTab components for handling customs payments and payment summaries

This commit is contained in:
Marshal
2026-08-20 15:45:42 +00:00
committed by Hagernesh
parent 2e28b10610
commit c5909b8ec7
57 changed files with 3255 additions and 787 deletions

View File

@@ -1,6 +1,7 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Alert,
Badge,
Box,
Button,
@@ -12,6 +13,7 @@ import {
Select,
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import {
@@ -19,9 +21,11 @@ import {
Download,
Eye,
FileText,
Lock,
Receipt,
Send,
Upload,
XCircle,
} from "lucide-react";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
@@ -42,11 +46,19 @@ const STATUS_META: Record<
{ label: string; color: string }
> = {
DOC_UPLOADED: { label: "Awaiting billing", color: "yellow" },
BILLED: { label: "Ready to send", color: "blue" },
SENT: { label: "Sent — unpaid", color: "orange" },
BILLED: { label: "Draft — not sent", color: "blue" },
SENT: { label: "Awaiting customer approval", color: "orange" },
REJECTED: { label: "Rejected by customer", color: "red" },
ACCEPTED: { label: "Accepted — invoice unpaid", color: "teal" },
PAID: { label: "Paid", color: "edr-green" },
};
/** Once the customer accepts, the invoice exists and GL can no longer edit. */
const isLocked = (s: Freight.ClearanceChargeStatus) =>
s === "ACCEPTED" || s === "PAID";
type BillInput = { amount: number; currency: string; description: string };
export interface ClearanceChargesTabProps {
bookingId: string;
/** DJ uploads the port document; ET bills, sends and creates miscellaneous. */
@@ -55,11 +67,12 @@ export interface ClearanceChargesTabProps {
}
/**
* Post-finalization charges billed to the customer, two levels: port charges
* (document from GL Djibouti, billed by GL Ethiopia) then miscellaneous
* (created whole by GL Ethiopia once the port charge is paid). Each level
* issues its own payable invoice — ETB settles through the portal gateway
* (CBE), other currencies through Finance's manual settlement.
* Post-finalization charges billed to the customer: port charges (document
* from GL Djibouti, priced by GL Ethiopia) and any number of miscellaneous
* charges. GL prices + describes a charge and sends it; the customer accepts
* (invoice issued, charge locked) or rejects with a note (GL revises and
* re-sends). ETB settles through the portal gateway (CBE), other currencies
* through Finance's manual settlement.
*/
export function ClearanceChargesTab({
bookingId,
@@ -89,7 +102,7 @@ export function ClearanceChargesTab({
onError,
});
const bill = useMutation({
mutationFn: (p: { chargeId: string; amount: number; currency: string }) =>
mutationFn: (p: BillInput & { chargeId: string }) =>
bookingsService.billClearanceCharge(bookingId, p.chargeId, p),
onSuccess: (next) => {
toast.success("Charge amount saved");
@@ -101,13 +114,13 @@ export function ClearanceChargesTab({
mutationFn: (chargeId: string) =>
bookingsService.sendClearanceCharge(bookingId, chargeId),
onSuccess: (next) => {
toast.success("Invoice sent to the customer");
toast.success("Sent to the customer for approval");
refresh(next);
},
onError,
});
const createMisc = useMutation({
mutationFn: (p: { file: File; amount: number; currency: string }) =>
mutationFn: (p: BillInput & { file: File }) =>
bookingsService.createMiscellaneousCharge(bookingId, p.file, p),
onSuccess: (next) => {
toast.success("Miscellaneous charge created");
@@ -153,9 +166,7 @@ export function ClearanceChargesTab({
: "Waiting for GL Djibouti to upload the port-charges document."
}
onViewFile={onViewFile}
onBill={(amount, currency) =>
port && bill.mutate({ chargeId: port.id, amount, currency })
}
onBill={(input) => port && bill.mutate({ chargeId: port.id, ...input })}
onSend={() => port && send.mutate(port.id)}
djUpload={
roleMode === "DJ" && (!port || port.status === "DOC_UPLOADED") ? (
@@ -196,9 +207,7 @@ export function ClearanceChargesTab({
busy={busy}
emptyHint=""
onViewFile={onViewFile}
onBill={(amount, currency) =>
bill.mutate({ chargeId: c.id, amount, currency })
}
onBill={(input) => bill.mutate({ chargeId: c.id, ...input })}
onSend={() => send.mutate(c.id)}
/>
))}
@@ -211,15 +220,13 @@ export function ClearanceChargesTab({
: "Add a miscellaneous charge"}
</Text>
<Text fz="12px" c="dimmed" mb="sm">
Upload the supporting document and set the amount. You can raise as
many as the shipment needs, before or after the port charge.
Upload the supporting document, set the amount and say what it is
for. The customer sees it once you send it for approval.
</Text>
<MiscCreateForm
key={miscCreated}
busy={createMisc.isPending}
onCreate={(file, amount, currency) =>
createMisc.mutate({ file, amount, currency })
}
onCreate={(file, input) => createMisc.mutate({ file, ...input })}
/>
</Paper>
)}
@@ -265,7 +272,7 @@ function ChargeCard({
busy: boolean;
emptyHint: string;
onViewFile: (file: { name: string; url: string }) => void;
onBill: (amount: number, currency: string) => void;
onBill: (input: BillInput) => void;
onSend: () => void;
djUpload?: React.ReactNode;
etCreate?: React.ReactNode;
@@ -273,13 +280,17 @@ function ChargeCard({
const [editing, setEditing] = useState(false);
const [amount, setAmount] = useState<number | string>(charge?.amount ?? "");
const [currency, setCurrency] = useState<string>(charge?.currency ?? "ETB");
const [description, setDescription] = useState(charge?.description ?? "");
const status = charge?.status ?? null;
const meta = status ? STATUS_META[status] : null;
// ET enters/revises the amount while the charge is unpaid.
const locked = status != null && isLocked(status);
const needsDescription = charge?.type === "MISCELLANEOUS";
// ET enters/revises the price until the customer accepts it.
const showBillForm =
roleMode === "ET" &&
charge != null &&
!locked &&
(charge.status === "DOC_UPLOADED" || editing);
return (
@@ -304,6 +315,12 @@ function ChargeCard({
{formatDateTime(charge.billedAt)}
</Text>
)}
{charge?.status === "ACCEPTED" && charge.customerDecidedAt && (
<Text fz="11.5px" c="teal.8" fw={600}>
Accepted by the customer · {formatDateTime(charge.customerDecidedAt)}
{charge.invoiceNumber ? ` (invoice ${charge.invoiceNumber})` : ""}
</Text>
)}
{charge?.paidAt && (
<Text fz="11.5px" c="edr-green.8" fw={600}>
Paid · {formatDateTime(charge.paidAt)}
@@ -379,6 +396,32 @@ function ChargeCard({
</Group>
)}
{charge?.description && !showBillForm && (
<Text fz="12.5px" c="edr-text" mt="xs">
{charge.description}
</Text>
)}
{charge?.status === "REJECTED" && charge.customerNote && (
<Alert
color="red"
variant="light"
radius="md"
p="xs"
mt="sm"
icon={<XCircle size={16} />}
title="Rejected by the customer"
>
<Text fz="12.5px">{charge.customerNote}</Text>
{charge.customerDecidedAt && (
<Text fz="11px" c="dimmed" mt={4}>
{formatDateTime(charge.customerDecidedAt)} fix the price or
description and send it again.
</Text>
)}
</Alert>
)}
{!charge && (
<Text fz="12.5px" c="dimmed" mt="xs">
{emptyHint}
@@ -389,6 +432,15 @@ function ChargeCard({
{showBillForm && (
<Group mt="sm" gap={8} align="flex-end" wrap="wrap">
<TextInput
label={needsDescription ? "What is this charge for?" : "Description (optional)"}
size="xs"
radius="md"
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
maxLength={1000}
w={320}
/>
<NumberInput
label="Amount"
size="xs"
@@ -412,13 +464,21 @@ function ChargeCard({
size="compact-sm"
color="edr-green"
radius="md"
disabled={busy || !(Number(amount) > 0)}
disabled={
busy ||
!(Number(amount) > 0) ||
(needsDescription && !description.trim())
}
onClick={() => {
onBill(Number(amount), currency);
onBill({
amount: Number(amount),
currency,
description: description.trim(),
});
setEditing(false);
}}
>
Save amount
Save
</Button>
{editing && (
<Button
@@ -435,7 +495,7 @@ function ChargeCard({
</Group>
)}
{roleMode === "ET" && charge && !showBillForm && charge.status !== "PAID" && (
{roleMode === "ET" && charge && !showBillForm && !locked && (
<Group mt="sm" gap={8} justify="flex-end">
<Button
size="compact-sm"
@@ -446,13 +506,14 @@ function ChargeCard({
onClick={() => {
setAmount(charge.amount ?? "");
setCurrency(charge.currency ?? "ETB");
setDescription(charge.description ?? "");
setEditing(true);
}}
>
{charge.status === "SENT" ? "Revise (cancels invoice)" : "Edit amount"}
{charge.status === "SENT" ? "Revise" : "Edit"}
</Button>
{charge.status === "BILLED" && (
<Tooltip label="ETB is payable online via CBE; other currencies go to Finance's manual settlement.">
{(charge.status === "BILLED" || charge.status === "REJECTED") && (
<Tooltip label="The customer accepts or rejects the price in the portal; the invoice is issued when they accept.">
<Button
size="compact-sm"
color="edr-green"
@@ -461,15 +522,20 @@ function ChargeCard({
disabled={busy}
onClick={onSend}
>
Send invoice to customer
{charge.status === "REJECTED"
? "Send again for approval"
: "Send to customer for approval"}
</Button>
</Tooltip>
)}
{charge.status === "SENT" && charge.invoiceNumber && (
<Badge variant="light" color="orange" radius="sm">
Invoice {charge.invoiceNumber}
</Badge>
)}
</Group>
)}
{charge?.status === "ACCEPTED" && (
<Group mt="sm" gap={6} justify="flex-end">
<Lock size={14} color="var(--mantine-color-teal-7)" />
<Text fz="12px" c="teal.8" fw={600}>
Locked invoice {charge.invoiceNumber ?? ""} awaiting payment
</Text>
</Group>
)}
{charge?.status === "PAID" && (
@@ -489,14 +555,25 @@ function MiscCreateForm({
onCreate,
}: {
busy: boolean;
onCreate: (file: File, amount: number, currency: string) => void;
onCreate: (file: File, input: BillInput) => void;
}) {
const [file, setFile] = useState<File | null>(null);
const [amount, setAmount] = useState<number | string>("");
const [currency, setCurrency] = useState("ETB");
const [description, setDescription] = useState("");
return (
<Group gap={8} align="flex-end" wrap="wrap">
<TextInput
label="What is this charge for?"
placeholder="e.g. Container cleaning and weighbridge fee"
size="xs"
radius="md"
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
maxLength={1000}
w={320}
/>
<FileButton onChange={setFile} accept="application/pdf,image/*" disabled={busy}>
{(props) => (
<Button
@@ -534,9 +611,16 @@ function MiscCreateForm({
size="compact-sm"
color="edr-green"
radius="md"
disabled={busy || !file || !(Number(amount) > 0)}
disabled={busy || !file || !(Number(amount) > 0) || !description.trim()}
loading={busy}
onClick={() => file && onCreate(file, Number(amount), currency)}
onClick={() =>
file &&
onCreate(file, {
amount: Number(amount),
currency,
description: description.trim(),
})
}
>
Create charge
</Button>

View File

@@ -199,7 +199,13 @@ export function RequestServiceTypeCard({
if (lastMile)
chips.push({ label: "Last-mile delivery", color: "teal", icon: Warehouse });
if (customs)
chips.push({ label: "Customs clearance (GL)", color: "grape", icon: FileCheck });
chips.push({
label: st.includesEthiopianCustomsOnly
? "Ethiopian customs clearance (GL)"
: "Customs clearance (GL)",
color: "grape",
icon: FileCheck,
});
return (
<SectionCard

View File

@@ -0,0 +1,312 @@
import {
Alert,
Badge,
Button,
Group,
Loader,
NumberInput,
Paper,
Select,
SimpleGrid,
Stack,
Table,
Text,
Tooltip,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import { AlertTriangle, Lock, MapPin } from "lucide-react";
import { useMemo, useState } from "react";
import { useToast } from "@/hooks/use-toast";
import { api } from "@/services/api";
import type { ScheduleWagonYardRow } from "@/services/trainBuilder.service";
/**
* Schedule yards tab: where THIS departure plans to board each consist wagon,
* side by side with where the wagon physically stands (the train builder's
* truth). Booking capacity per origin reads the plan; dispatch refuses to
* leave until plan and physical yards agree. Edits are queued locally and
* saved in one PATCH.
*/
const parseError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
const message = error.response?.data?.message;
if (Array.isArray(message)) return message.join(", ");
if (typeof message === "string") return message;
}
return fallback;
};
interface Props {
scheduleId: string;
canEdit: boolean;
}
export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
const { toast } = useToast();
const query = useQuery(
api.trainScheduling.scheduleWagonYards.queryOptions({ input: { scheduleId } }),
);
const save = useMutation(api.trainScheduling.updateScheduleWagonYards.mutationOptions());
const data = query.data;
/** wagonId → yardId queued but not yet saved. */
const [pending, setPending] = useState<Record<string, string>>({});
const [bulkType, setBulkType] = useState<string | null>(null);
const [bulkFrom, setBulkFrom] = useState<string | null>(null);
const [bulkTo, setBulkTo] = useState<string | null>(null);
const [bulkCount, setBulkCount] = useState<number | string>(1);
const editable = Boolean(canEdit && data?.editable);
const pickupStops = useMemo(() => (data?.stops ?? []).filter((s) => s.pickup), [data]);
const yardOptions = pickupStops.map((s) => ({ value: s.yardId, label: s.label }));
const yardLabel = (id: string | null) =>
(data?.stops ?? []).find((s) => s.yardId === id)?.label ??
data?.wagons.find((w) => w.plannedYardId === id)?.plannedYardLabel ??
data?.wagons.find((w) => w.physicalYardId === id)?.physicalYardLabel ??
id ??
"—";
const effectiveYard = (w: ScheduleWagonYardRow) => pending[w.id] ?? w.plannedYardId;
const perStop = useMemo(
() =>
(data?.stops ?? []).map((s) => ({
...s,
planned: (data?.wagons ?? []).filter((w) => (pending[w.id] ?? w.plannedYardId) === s.yardId)
.length,
})),
[data, pending],
);
const typeOptions = useMemo(() => {
const seen = new Map<string, string>();
for (const w of data?.wagons ?? []) seen.set(w.wagonType.id, w.wagonType.code);
return [...seen].map(([value, label]) => ({ value, label }));
}, [data]);
const pendingCount = Object.keys(pending).length;
const queueBulk = () => {
if (!data || !bulkFrom || !bulkTo || bulkFrom === bulkTo) return;
const n = Number(bulkCount) || 0;
const picked = data.wagons
.filter(
(w) =>
!w.locked &&
effectiveYard(w) === bulkFrom &&
(!bulkType || w.wagonType.id === bulkType),
)
.slice(0, n);
if (!picked.length) {
toast({ title: "No free wagons match", variant: "destructive" });
return;
}
setPending((prev) => {
const next = { ...prev };
for (const w of picked) {
if (w.plannedYardId === bulkTo) delete next[w.id];
else next[w.id] = bulkTo;
}
return next;
});
};
const handleSave = async () => {
if (!pendingCount) return;
try {
const result = await save.mutateAsync({
scheduleId,
payload: {
moves: Object.entries(pending).map(([wagonId, yardId]) => ({ wagonId, yardId })),
},
});
setPending({});
toast({
title: `Schedule yards updated — ${pendingCount} wagon(s) re-planned`,
description: result.warnings.length ? result.warnings.join(" ") : undefined,
variant: result.warnings.length ? "destructive" : undefined,
});
} catch (err) {
toast({
title: "Update failed",
description: parseError(err, "Could not update the schedule's wagon yards"),
variant: "destructive",
});
}
};
if (query.isLoading) return <Loader size="sm" />;
if (query.isError || !data) {
return (
<Alert color="red" icon={<AlertTriangle size={16} />}>
{parseError(
query.error,
"This schedule has no wagon yard plan (not created from a built train).",
)}
</Alert>
);
}
return (
<Stack gap="md">
<Alert color="blue" icon={<MapPin size={16} />} variant="light">
<b>Planned</b> = where this departure boards the wagon (what customers can book per
origin). <b>Physical</b> = where the wagon stands now (train builder). Dispatch is blocked
until every wagon stands at its planned yard.
{data.misaligned > 0 ? (
<Text component="span" c="orange" fw={600}>
{" "}
{data.misaligned} wagon(s) currently misaligned.
</Text>
) : null}
</Alert>
<SimpleGrid cols={{ base: 2, sm: 3, md: Math.min(5, Math.max(2, perStop.length)) }}>
{perStop.map((s) => (
<Paper key={s.yardId} withBorder p="sm" radius="md">
<Group justify="space-between" mb={4}>
<Text fw={600} size="sm">
{s.label}
</Text>
{!s.pickup ? (
<Badge size="xs" color="gray" variant="light">
destination
</Badge>
) : null}
</Group>
<Group gap="xs">
<Badge color="edr-green" variant="filled">
Planned {s.planned}
</Badge>
<Badge color={s.physical === s.planned ? "gray" : "orange"} variant="light">
Physical {s.physical}
</Badge>
</Group>
</Paper>
))}
</SimpleGrid>
{editable ? (
<Paper withBorder p="sm" radius="md">
<Group align="end" gap="sm" wrap="wrap">
<NumberInput
label="Move"
min={1}
max={data.wagons.length}
value={bulkCount}
onChange={setBulkCount}
w={90}
/>
<Select
label="Wagon type"
placeholder="Any"
clearable
data={typeOptions}
value={bulkType}
onChange={setBulkType}
w={140}
/>
<Select label="From" data={yardOptions} value={bulkFrom} onChange={setBulkFrom} w={170} />
<Select label="To" data={yardOptions} value={bulkTo} onChange={setBulkTo} w={170} />
<Button
variant="light"
onClick={queueBulk}
disabled={!bulkFrom || !bulkTo || bulkFrom === bulkTo}
>
Queue
</Button>
</Group>
</Paper>
) : null}
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>#</Table.Th>
<Table.Th>Wagon</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Physical yard</Table.Th>
<Table.Th>Planned yard (this schedule)</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data.wagons.map((w) => {
const planned = effectiveYard(w);
const changed = w.id in pending;
return (
<Table.Tr key={w.id} bg={changed ? "var(--mantine-color-yellow-light)" : undefined}>
<Table.Td>{w.sequenceNumber ?? "—"}</Table.Td>
<Table.Td>
<Text fw={600} size="sm">
{w.wagonNumber}
</Text>
</Table.Td>
<Table.Td>{w.wagonType.code}</Table.Td>
<Table.Td>{w.physicalYardLabel ?? "No yard"}</Table.Td>
<Table.Td>
{editable && !w.locked ? (
<Select
size="xs"
data={yardOptions}
value={planned}
onChange={(v) =>
setPending((prev) => {
const next = { ...prev };
if (!v || v === w.plannedYardId) delete next[w.id];
else next[w.id] = v;
return next;
})
}
w={180}
/>
) : (
<Group gap={4}>
<Text size="sm">{yardLabel(planned)}</Text>
{w.locked ? (
<Tooltip label={w.lockReason ?? "Locked"}>
<Lock size={14} />
</Tooltip>
) : null}
</Group>
)}
</Table.Td>
<Table.Td>
{planned === w.physicalYardId ? (
<Badge color="teal" variant="light" size="sm">
Aligned
</Badge>
) : (
<Badge color="orange" variant="light" size="sm">
Needs move
</Badge>
)}
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
{editable ? (
<Group justify="flex-end">
<Text size="sm" c="dimmed">
{pendingCount} pending change(s)
</Text>
<Button variant="default" onClick={() => setPending({})} disabled={!pendingCount}>
Discard
</Button>
<Button
onClick={() => void handleSave()}
loading={save.isPending}
disabled={!pendingCount}
>
Save plan
</Button>
</Group>
) : null}
</Stack>
);
}

View File

@@ -59,6 +59,7 @@ import {
import {
DEFAULT_CONFIGURATION_SLUG,
DEFAULT_RULES_SLUG,
ROUTE_SCOPED_TRIGGERS,
RULE_ENGINE_CATEGORY_BASE_PATH,
RULE_ENGINE_SELECT_NONE,
getRuleEngineResource,
@@ -120,9 +121,7 @@ const yardOptionsForLegEnd = (
// direction + route, so their yard dropdowns narrow exactly like base
// freight.
(appliesTo === "OTHER" &&
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(
String(values.trigger ?? ""),
))
ROUTE_SCOPED_TRIGGERS.includes(String(values.trigger ?? "")))
) {
const direction = String(values.tradeDirection ?? "");
// Direction is what decides the countries, so offer nothing until it is set

View File

@@ -221,6 +221,10 @@ const RATE_TRIGGERS = [
{ label: "Cancellation (per wagon, per direction + cargo type)", value: "CANCELLATION" },
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
{ label: "Customs clearance service fee (billed with the booking)", value: "CUSTOMS_CLEARANCE" },
{
label: "Ethiopian customs clearance service fee (Ethiopian-side-only services)",
value: "ETHIOPIAN_CUSTOMS_CLEARANCE",
},
{ label: "Fuel (per lane + cargo type)", value: "FUEL" },
];
@@ -279,8 +283,16 @@ const SHIPPING_LINE_CARGO_KINDS = [
const isBaseFreightRate = (values: Record<string, unknown>) =>
["BULK", "CONTAINER", "INTERCITY"].includes(String(values.appliesTo ?? ""));
/** Surcharges sold per origin → destination leg (mirrors RatesService.isRouteScoped). */
export const ROUTE_SCOPED_TRIGGERS = [
"CUSTOMS_CLEARANCE",
"ETHIOPIAN_CUSTOMS_CLEARANCE",
"WITH_RETURN",
"FUEL",
];
/**
* Rates priced per leg: base rail freight, plus the customs clearance fee and
* Rates priced per leg: base rail freight, plus the customs clearance fees and
* the empty-container return surcharge (sold per route + container type).
*/
const isRouteScopedRate = (values: Record<string, unknown>) =>
@@ -289,19 +301,19 @@ const isRouteScopedRate = (values: Record<string, unknown>) =>
(isShippingLineRate(values)
? hasShippingLine(values) &&
(values.shippingLineRateKind === "BASE" ||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(
String(values.trigger ?? ""),
))
ROUTE_SCOPED_TRIGGERS.includes(String(values.trigger ?? "")))
: isBaseFreightRate(values)) ||
(String(values.appliesTo ?? "") === "OTHER" &&
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(String(values.trigger ?? "")));
ROUTE_SCOPED_TRIGGERS.includes(String(values.trigger ?? "")));
/**
* Surcharges sold per cargo kind: the admin says container or bulk, then names
* the container type or bulk commodity the fee covers.
*/
const isCargoKindTrigger = (values: Record<string, unknown>) =>
["CUSTOMS_CLEARANCE", "CANCELLATION"].includes(String(values.trigger ?? ""));
["CUSTOMS_CLEARANCE", "ETHIOPIAN_CUSTOMS_CLEARANCE", "CANCELLATION"].includes(
String(values.trigger ?? ""),
);
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
@@ -345,6 +357,7 @@ const unitsForShape = (
// Wagon cancellation fee — scales with the cancelled wagons only.
return ["PER_WAGON"];
case "CUSTOMS_CLEARANCE":
case "ETHIOPIAN_CUSTOMS_CLEARANCE":
// Per cargo kind: container fees per box/wagon, bulk per ton/wagon.
return cargoKind === "BULK"
? ["PER_TON", "PER_WAGON"]
@@ -876,6 +889,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ name: "includesFirstMile", label: "Includes first mile", type: "boolean" },
{ name: "includesLastMile", label: "Includes last mile", type: "boolean" },
{ name: "includesCustoms", label: "Includes customs", type: "boolean" },
{
name: "includesEthiopianCustomsOnly",
label: "Ethiopian customs only",
type: "boolean",
description:
"EDR clears customs on the Ethiopian side only. Same clearance flow; contracts and bookings price off the Ethiopian customs clearance rate instead of the standard one.",
showIf: (v) => v.includesCustoms === true,
},
{ name: "isActive", label: "Active", type: "boolean" },
],
},
@@ -1081,6 +1102,11 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
label: "Customs clearance",
filters: { trigger: "CUSTOMS_CLEARANCE", isShippingLineRate: "false" },
},
{
key: "ethiopian-customs",
label: "Ethiopian customs",
filters: { trigger: "ETHIOPIAN_CUSTOMS_CLEARANCE", isShippingLineRate: "false" },
},
{
key: "return",
label: "Container return",
@@ -1235,6 +1261,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
(String(v.appliesTo ?? "") === "OTHER" &&
[
"CUSTOMS_CLEARANCE",
"ETHIOPIAN_CUSTOMS_CLEARANCE",
"CANCELLATION",
"WITH_RETURN",
"LASHING",

View File

@@ -41,6 +41,7 @@ import {
Train,
Weight,
Workflow as WorkflowIcon,
Warehouse,
} from "lucide-react";
import { DateTimePicker } from "@mantine/dates";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -59,6 +60,7 @@ import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPl
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel";
import { ScheduleWagonYardPanel } from "@/components/trainScheduling/ScheduleWagonYardPanel";
import { LegLoadBoardPanel } from "@/components/trainScheduling/LegLoadBoardPanel";
import { LoadEmptyContainersModal } from "@/components/trainScheduling/LoadEmptyContainersModal";
import MergeScheduleTrainModal from "@/components/trainScheduling/MergeScheduleTrainModal";
@@ -1287,6 +1289,9 @@ export default function TrainScheduleV2DetailPage() {
<Tabs.Tab value="leg-board" leftSection={<Grid3x3 size={16} />}>
Leg board
</Tabs.Tab>
<Tabs.Tab value="wagon-yards" leftSection={<Warehouse size={16} />}>
Schedule yards
</Tabs.Tab>
<Tabs.Tab value="history" leftSection={<HistoryIcon size={16} />}>
History
</Tabs.Tab>
@@ -1382,6 +1387,15 @@ export default function TrainScheduleV2DetailPage() {
/>
</Tabs.Panel>
<Tabs.Panel value="wagon-yards">
{scheduleId ? (
<ScheduleWagonYardPanel
scheduleId={scheduleId}
canEdit={hasPermission(authUser, FREIGHT_PERMS.trainScheduling.update)}
/>
) : null}
</Tabs.Panel>
<Tabs.Panel value="history">
{scheduleId ? <ScheduleHistoryPanel scheduleId={scheduleId} /> : null}
</Tabs.Panel>

View File

@@ -232,6 +232,9 @@ import {
type BuiltTrainListFilters,
type BuiltTrainListResponse,
type ScheduleConsist,
type ScheduleWagonYards,
type UpdateScheduleWagonYardsPayload,
type UpdateScheduleWagonYardsResult,
type ScheduleHistoryEntry,
type TrainComposition,
type UpdateTrainDetailsPayload,
@@ -416,6 +419,30 @@ export const api = {
],
),
scheduleWagonYards: endpoint<{ scheduleId: string }, ScheduleWagonYards>(
"train-scheduling",
"schedule-wagon-yards",
({ scheduleId }) =>
trainBuilderService.scheduleWagonYards(scheduleId).then((r) => r.data),
({ scheduleId }) => [
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
"wagon-yards",
scheduleId,
],
),
updateScheduleWagonYards: endpoint<
{ scheduleId: string; payload: UpdateScheduleWagonYardsPayload },
UpdateScheduleWagonYardsResult
>(
"train-scheduling",
"update-schedule-wagon-yards",
({ scheduleId, payload }) =>
trainBuilderService.updateScheduleWagonYards(scheduleId, payload).then((r) => r.data),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
adjustConsist: endpoint<
{ scheduleId: string; payload: AdjustConsistPayload },
AdjustConsistResult

View File

@@ -466,11 +466,11 @@ export const bookingsService = {
return unwrap(response.data) as Freight.ClearanceCharge[];
},
/** GL Ethiopia sets or revises a charge's amount + currency. */
/** GL Ethiopia sets or revises a charge's amount, currency and description. */
billClearanceCharge: async (
id: string,
chargeId: string,
payload: { amount: number; currency: string },
payload: { amount: number; currency: string; description?: string },
): Promise<Freight.ClearanceCharge[]> => {
const response = await client.patch(
`/bookings/${id}/clearance/charges/${chargeId}/bill`,
@@ -479,7 +479,7 @@ export const bookingsService = {
return unwrap(response.data) as Freight.ClearanceCharge[];
},
/** GL Ethiopia issues the charge's payable invoice to the customer. */
/** GL Ethiopia sends the priced charge to the customer for approval. */
sendClearanceCharge: async (
id: string,
chargeId: string,
@@ -490,16 +490,17 @@ export const bookingsService = {
return unwrap(response.data) as Freight.ClearanceCharge[];
},
/** GL Ethiopia creates the miscellaneous charge (document + amount + currency). */
/** GL Ethiopia creates a miscellaneous charge (document + amount + currency + description). */
createMiscellaneousCharge: async (
id: string,
file: File,
payload: { amount: number; currency: string },
payload: { amount: number; currency: string; description: string },
): Promise<Freight.ClearanceCharge[]> => {
const form = new FormData();
form.append("file", file);
form.append("amount", String(payload.amount));
form.append("currency", payload.currency);
form.append("description", payload.description);
const response = await client.post(
`/bookings/${id}/clearance/charges/miscellaneous`,
form,

View File

@@ -300,6 +300,45 @@ export interface ScheduleHistoryEntry {
/** Adjust response: fresh consist + schedule-impact warnings to surface. */
export type AdjustConsistResult = ScheduleConsist & { warnings: string[] };
/** One consist wagon in the schedule-yards tab: where this departure plans it vs where it stands. */
export interface ScheduleWagonYardRow {
id: string;
wagonNumber: string;
sequenceNumber: number | null;
wagonType: { id: string; code: string; name: string };
physicalYardId: string | null;
physicalYardLabel: string | null;
plannedYardId: string | null;
plannedYardLabel: string | null;
aligned: boolean;
locked: boolean;
lockReason: string | null;
}
export interface ScheduleWagonYardStop {
yardId: string;
label: string;
/** Origin or intermediate stop — wagons can board here. The destination cannot. */
pickup: boolean;
planned: number;
physical: number;
}
export interface ScheduleWagonYards {
scheduleId: string;
train: { id: string; code: string };
editable: boolean;
stops: ScheduleWagonYardStop[];
wagons: ScheduleWagonYardRow[];
misaligned: number;
}
export interface UpdateScheduleWagonYardsPayload {
moves: Array<{ wagonId: string; yardId: string }>;
}
export type UpdateScheduleWagonYardsResult = ScheduleWagonYards & { warnings: string[] };
export const trainBuilderService = {
list: (filters: BuiltTrainListFilters = {}) =>
apiClient.get<BuiltTrainListResponse>(`${BASE}${toQuery(filters)}`),
@@ -350,6 +389,15 @@ export const trainBuilderService = {
`/train-scheduling/schedules/${scheduleId}/adjust-consist`,
payload,
),
/** Schedule-only wagon yard plan (where THIS departure boards each wagon). */
scheduleWagonYards: (scheduleId: string) =>
apiClient.get<ScheduleWagonYards>(`/train-scheduling/schedules/${scheduleId}/wagon-yards`),
/** Re-plan boarding yards for this schedule; physical wagon yards untouched. */
updateScheduleWagonYards: (scheduleId: string, payload: UpdateScheduleWagonYardsPayload) =>
apiClient.patch<UpdateScheduleWagonYardsResult>(
`/train-scheduling/schedules/${scheduleId}/wagon-yards`,
payload,
),
/** Unified wagon/booking change history for the schedule's History tab. */
scheduleHistory: (scheduleId: string) =>
apiClient.get<ScheduleHistoryEntry[]>(

View File

@@ -2,8 +2,8 @@ import { Box, Group, Stack, Text } from "@mantine/core";
import { memo } from "react";
import { ACTION_PROPS, STATUS_CONFIG, cv } from "../constants";
import { Stepper } from "./Stepper";
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
import { payWindowState } from "@/pages/bookings/payments/payment-drain";
import { PayButton } from "@/pages/bookings/payments/PayButton";
import { useMyPayables } from "@/pages/bookings/payments/useMyPayables";
import { BookingActionButton } from "@/pages/bookings/clearance/BookingActionButton";
import { bookingHasInlineAction } from "@/pages/bookings/clearance/bookingNextAction";
import {
@@ -28,21 +28,9 @@ export const BookingRow = memo(function BookingRow({
const Icon = cfg.icon;
const AIcon = cfg.action.icon;
const ap = ACTION_PROPS[cfg.action.kind];
// Payable bookings get an inline "Pay now" that opens the payment modal
// instead of navigating to the detail page. A general contract is payable as
// soon as it's FULLY_EXECUTED (signed); a one-time booking only after it's
// SELECTED_FOR_BATCH — same rule as the bookings list's PrimaryAction.
const payableStatus =
booking.bookingType === "GENERAL_CONTRACT"
? "FULLY_EXECUTED"
: "SELECTED_FOR_BATCH";
// A fully-closed pay window (deadline + drain both elapsed) has nothing to pay
// against, so the row falls back to its normal action instead of an empty slot.
// The drain itself still routes here — PayNowButton renders the wait notice.
const canPay =
booking.status === payableStatus &&
booking.paymentStatus !== "PAID" &&
payWindowState(booking).phase !== "closed";
// Anything outstanding (freight, clearance charge, duty slip, cancellation
// fee) → "Pay" jumps to the booking's Payments tab. One shared query.
const payable = useMyPayables().get(booking.id);
// Clearance/operation steps + changes-requested resubmit can be done in place
// via a modal on the row.
const hasInlineAction = bookingHasInlineAction(booking);
@@ -113,8 +101,8 @@ export const BookingRow = memo(function BookingRow({
{cfg.badgeLabel}
</Text>
</Group>
{canPay ? (
<PayNowButton booking={booking} size="sm" />
{payable ? (
<PayButton bookingId={booking.id} summary={payable} size="sm" />
) : canSign ? (
<ContractSignButton booking={booking} size="sm" />
) : canApproveDelivery ? (

View File

@@ -1,11 +1,11 @@
import { useState } from "react";
import {
useState,
} from "react";
import {
Alert,
Anchor,
Badge,
Box,
Button,
FileInput,
Group,
Paper,
Stack,
@@ -15,19 +15,20 @@ import {
import {
AlertTriangle,
Check,
Download,
Eye,
FileBadge,
MessageSquareWarning,
Receipt,
Upload,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import {
useQuery,
} from "@tanstack/react-query";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import { bookingsService } from "@/services/bookings.service";
import { contractsService } from "@/services/contracts.service";
import {
bookingsService,
} from "@/services/bookings.service";
import { downloadStoredFile } from "@/services/files.service";
import { ClearancePhaseStepper } from "../contracts/ClearancePhaseStepper";
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
@@ -36,29 +37,6 @@ import { GREEN, INK } from "../contracts/contract-ui";
const BORDER = "#E6ECF2";
// Customer-facing labels for an invoice status (Freight.InvoiceStatus).
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",
CANCELLED: "Cancelled",
REFUNDED: "Refunded",
EXPIRED: "Expired",
};
function invoiceStatusLabel(status: string): string {
return (
INVOICE_STATUS_LABELS[status] ??
status
.replace(/_/g, " ")
.toLowerCase()
.replace(/\b\w/g, (m) => m.toUpperCase())
);
}
@@ -81,13 +59,9 @@ export function BookingClearanceWorkflowBanner({
if (!isPhased || !clearance) return null;
const dutyPaid = clearance.milestones?.some(
(m) => m.milestoneCode === "DUTY_TAX_PAID" && m.status === "COMPLETED",
);
const dutyPending =
clearance.dutyRequired &&
clearance.dutyAdvice &&
!dutyPaid;
// Duty / tax, additional duty and the final invoice are paid from the
// booking's Payments tab (CustomsPaymentsCard); this banner keeps the
// progress, the draft declaration and the documents.
// A change request clears the draft while it's open — show the "waiting on
// GL" state instead of the review panel until GL sends a corrected draft.
const draftDeclarationChangeRequestPending = Boolean(
@@ -131,14 +105,6 @@ export function BookingClearanceWorkflowBanner({
/>
) : null}
{dutyPending && clearance.dutyAdvice ? (
<DutyAdvicePanel
dutyAdvice={clearance.dutyAdvice}
bookingId={booking.id}
onChanged={() => void refetch()}
/>
) : null}
{clearance.riskLevel ? (
<Group gap={10} align="center">
<Text fw={700} fz={14} c={INK}>
@@ -160,24 +126,6 @@ export function BookingClearanceWorkflowBanner({
</Group>
) : null}
{clearance.secondDuty?.advised ? (
<SecondDutyDueCard
duty={clearance.secondDuty}
bookingId={booking.id}
onView={(f) => view(f)}
onChanged={() => void refetch()}
/>
) : null}
{clearance.finalInvoice ? (
<FinalInvoiceDueCard
invoice={clearance.finalInvoice}
bookingId={booking.id}
onView={(f) => view(f)}
onChanged={() => void refetch()}
/>
) : null}
{clearance.operationReady ? (
<Alert color="green" variant="light">
Clearance is complete. You may proceed to request your operation date.
@@ -197,76 +145,6 @@ export function BookingClearanceWorkflowBanner({
);
}
function DutyAdvicePanel({
dutyAdvice,
bookingId,
onChanged,
}: {
dutyAdvice: NonNullable<Freight.ClearanceView["dutyAdvice"]>;
bookingId: string;
onChanged: () => void;
}) {
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const noticeFile = dutyAdvice.noticeFile;
return (
<Paper withBorder radius="md" p="md" bg="#FFFBF0">
<Stack gap="sm">
<GroupLabel icon={Receipt} text="Duty / tax payment" />
<Text size="sm">
Amount due:{" "}
<strong>
{dutyAdvice.amount.toLocaleString()} {dutyAdvice.currency}
</strong>
{dutyAdvice.declarationSerial
? ` · Payment code: ${dutyAdvice.declarationSerial}`
: null}
</Text>
{noticeFile ? (
<Anchor
component="button"
type="button"
onClick={() => void downloadStoredFile(noticeFile.id, noticeFile.name)}
size="sm"
>
<Group gap={6} wrap="nowrap">
<Download size={14} />
Download duty notice ({noticeFile.name})
</Group>
</Anchor>
) : null}
<Text size="sm" c="dimmed">
Pay the amount above, then upload your payment slip so clearance can continue.
</Text>
<FileInput label="Payment slip" value={file} onChange={setFile} size="sm" />
<Button
color="orange"
loading={loading}
disabled={!file}
leftSection={<Upload size={16} />}
onClick={async () => {
if (!file) return;
setLoading(true);
try {
await bookingsService.uploadBookingClearanceDutySlip(bookingId, file);
toast.success("Payment slip uploaded");
onChanged();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setLoading(false);
}
}}
>
Submit payment slip
</Button>
</Stack>
</Paper>
);
}
/**
* GL Ethiopia sent a draft customs declaration — an estimated price + files
* the customer must accept before the real declaration is filed, or send back
@@ -446,328 +324,8 @@ function GroupLabel({ icon: Icon, text }: { icon: typeof Receipt; text: string }
);
}
/**
* Post-offload final invoice from GL Djibouti (export): shows the due amount +
* invoice document; the customer pays offline and attaches the payment slip
* here, then GL confirms and the badge flips to PAID.
*/
function FinalInvoiceDueCard({
invoice,
bookingId,
onView,
onChanged,
}: {
invoice: NonNullable<Freight.ClearanceView["finalInvoice"]>;
bookingId: string;
onView: (file: { name: string; url: string }) => void;
onChanged: () => void;
}) {
const [slip, setSlip] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [approving, setApproving] = useState(false);
const paid = invoice.status === "PAID";
// GL Djibouti raises it as a draft: nothing is payable until the customer
// reviews the attached invoice and approves it.
const approved = Boolean(invoice.approvedAt);
return (
<Paper
withBorder
radius="lg"
p="lg"
style={{
borderColor: paid ? "#CDEBDD" : "#F2D9A6",
background: paid ? "#F6FBF8" : "#FFFBF2",
position: "relative",
overflow: "hidden",
}}
>
<Box
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 3,
background: paid ? GREEN : "#E3A93C",
}}
/>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<div>
<Group gap={8} align="center">
<FileBadge size={16} color={paid ? GREEN : "#B07C1F"} />
<Text fw={700} fz={15} c={INK}>
{paid
? "Final invoice paid"
: approved
? "Final invoice due"
: "Final invoice — your approval needed"}{" "}
{invoice.invoiceNumber}
</Text>
<Badge color={paid ? "edr-green" : "yellow"} variant="light" radius="sm">
{approved
? invoiceStatusLabel(invoice.status)
: "Awaiting your approval"}
</Badge>
</Group>
<Text fz={20} fw={800} mt={6} c={INK}>
{invoice.totalAmount.toLocaleString()} {invoice.currency}
</Text>
{invoice.description ? (
<Text fz={13} c="dimmed" mt={2}>
{invoice.description}
</Text>
) : null}
{!paid ? (
<Text fz={13} c="#9A6B1F" mt={6}>
{approved
? "Pay the amount above and attach your payment slip — Global Logistics will confirm the payment."
: "Review the invoice document from Global Logistics Djibouti and approve it to proceed with payment."}
</Text>
) : null}
</div>
<Stack gap="xs" miw={260}>
{invoice.invoiceFile ? (
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Eye size={15} />}
onClick={() =>
onView({
name: invoice.invoiceFile!.name,
url: invoice.invoiceFile!.url,
})
}
>
View invoice
</Button>
) : null}
{invoice.slipFile ? (
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Eye size={15} />}
onClick={() =>
onView({
name: invoice.slipFile!.name,
url: invoice.slipFile!.url,
})
}
>
View payment slip
</Button>
) : null}
{!paid && !approved ? (
<Button
color="edr-green"
radius="md"
size="sm"
loading={approving}
leftSection={<Check size={15} />}
onClick={async () => {
setApproving(true);
try {
await contractsService.approveFinalInvoice(bookingId);
toast.success("Invoice approved — you can now pay");
onChanged();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Approval failed");
} finally {
setApproving(false);
}
}}
>
Approve invoice
</Button>
) : null}
{!paid && approved ? (
<>
<FileInput
placeholder={
invoice.slipFile ? "Replace payment slip" : "Attach payment slip"
}
value={slip}
onChange={setSlip}
size="sm"
radius="md"
/>
<Button
color="edr-green"
radius="md"
size="sm"
loading={uploading}
disabled={!slip}
leftSection={<Upload size={15} />}
onClick={async () => {
if (!slip) return;
setUploading(true);
try {
await contractsService.uploadFinalInvoiceSlip(bookingId, slip);
setSlip(null);
toast.success("Payment slip attached");
onChanged();
} catch (e) {
toast.error(
e instanceof Error ? e.message : "Upload failed",
);
} finally {
setUploading(false);
}
}}
>
{invoice.slipFile ? "Replace slip" : "Submit payment slip"}
</Button>
</>
) : null}
</Stack>
</Group>
</Paper>
);
}
const CUSTOMS_RISK_COLOR: Record<string, string> = {
GREEN: "green",
YELLOW: "yellow",
RED: "red",
};
/**
* Post-arrival additional duty/tax round (import): GL advises an extra amount
* with a notice; the customer pays offline and attaches another slip here.
*/
function SecondDutyDueCard({
duty,
bookingId,
onView,
onChanged,
}: {
duty: NonNullable<Freight.ClearanceView["secondDuty"]>;
bookingId: string;
onView: (file: { name: string; url: string }) => void;
onChanged: () => void;
}) {
const [slip, setSlip] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const paid = duty.paid;
return (
<Paper
withBorder
radius="lg"
p="lg"
style={{
borderColor: paid ? "#CDEBDD" : "#F2D9A6",
background: paid ? "#F6FBF8" : "#FFFBF2",
position: "relative",
overflow: "hidden",
}}
>
<Box
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 3,
background: paid ? GREEN : "#E3A93C",
}}
/>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<div>
<Group gap={8} align="center">
<FileBadge size={16} color={paid ? GREEN : "#B07C1F"} />
<Text fw={700} fz={15} c={INK}>
{paid ? "Additional duty & tax paid" : "Additional duty & tax due"}
</Text>
<Badge color={paid ? "edr-green" : "yellow"} variant="light" radius="sm">
{paid ? "PAID" : "DUE"}
</Badge>
</Group>
<Text fz={20} fw={800} mt={6} c={INK}>
{(duty.amount ?? 0).toLocaleString()} {duty.currency ?? ""}
</Text>
{duty.declarationSerial ? (
<Text fz={13} c="dimmed" mt={2}>
Payment code: {duty.declarationSerial}
</Text>
) : null}
{!paid ? (
<Text fz={13} c="#9A6B1F" mt={6}>
Customs advised additional duty/tax after arrival. Pay the amount
above and attach your payment slip.
</Text>
) : null}
</div>
<Stack gap="xs" miw={260}>
{duty.noticeFile ? (
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Eye size={15} />}
onClick={() =>
onView({ name: duty.noticeFile!.name, url: duty.noticeFile!.url })
}
>
View duty notice
</Button>
) : null}
{duty.slipFile ? (
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Eye size={15} />}
onClick={() =>
onView({ name: duty.slipFile!.name, url: duty.slipFile!.url })
}
>
View payment slip
</Button>
) : null}
{!paid ? (
<>
<FileInput
placeholder={duty.slipFile ? "Replace payment slip" : "Attach payment slip"}
value={slip}
onChange={setSlip}
size="sm"
radius="md"
/>
<Button
color="edr-green"
radius="md"
size="sm"
loading={uploading}
disabled={!slip}
leftSection={<Upload size={15} />}
onClick={async () => {
if (!slip) return;
setUploading(true);
try {
await contractsService.uploadSecondDutySlip(bookingId, slip);
setSlip(null);
toast.success("Payment slip attached");
onChanged();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setUploading(false);
}
}}
>
{duty.slipFile ? "Replace slip" : "Submit payment slip"}
</Button>
</>
) : null}
</Stack>
</Group>
</Paper>
);
}

View File

@@ -252,9 +252,9 @@ export function BookingPaymentPanel({
};
return (
<SectionCard p={22}>
<SectionCard id="freight-payment" p={22}>
<Group justify="space-between" align="center">
<CardTitle>Payment</CardTitle>
<CardTitle>Freight payment</CardTitle>
<Group
component="span"
gap={6}

View File

@@ -0,0 +1,251 @@
import { useState } from "react";
import { Alert, Box, Button, Group, Stack, Text, Textarea } from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, CreditCard, Receipt, X } from "lucide-react";
import { Link } from "react-router-dom";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
import { bookingsService } from "@/services/bookings.service";
import { formatAmount } from "../utils";
import { PaymentMethodModal } from "./PaymentMethodModal";
import { CardTitle, SectionCard } from "./layout";
const LABEL: Record<Freight.ClearanceChargeType, string> = {
PORT_CHARGES: "Port charges",
MISCELLANEOUS: "Miscellaneous charge",
};
const STATUS: Record<
Freight.ClearanceChargeStatus,
{ label: string; bg: string; fg: string }
> = {
DOC_UPLOADED: { label: "DRAFT", bg: "#EEF2F6", fg: "#64748B" },
BILLED: { label: "DRAFT", bg: "#EEF2F6", fg: "#64748B" },
SENT: { label: "NEEDS YOUR APPROVAL", bg: "#FEF3E2", fg: "#B45309" },
REJECTED: { label: "REJECTED", bg: "#FEE2E2", fg: "#B91C1C" },
ACCEPTED: { label: "ACCEPTED — UNPAID", bg: "#E0F2FE", fg: "#0369A1" },
PAID: { label: "PAID", bg: "#E6F7EF", fg: "#0A6F4D" },
};
const money = (c: Freight.ClearanceCharge) =>
`${formatAmount(c.amount)} ${c.currency ?? ""}`;
/**
* Clearance charges Global Logistics proposed for this shipment. The customer
* accepts a price (its invoice is then issued and payable here) or rejects it
* with a note so GL can revise. Renders nothing until GL sends a charge.
*/
export function ClearanceChargesSection({ bookingId }: { bookingId: string }) {
const qc = useQueryClient();
const key = ["booking-clearance-charges", bookingId];
const { data: charges = [] } = useQuery({
queryKey: key,
queryFn: () => bookingsService.getClearanceCharges(bookingId),
});
const [rejecting, setRejecting] = useState<string | null>(null);
const [note, setNote] = useState("");
const [payCharge, setPayCharge] = useState<Freight.ClearanceCharge | null>(null);
const pay = useInvoicePayment();
const onError = (e: unknown) =>
toast.error(e instanceof Error ? e.message : "Could not update the charge");
const accept = useMutation({
mutationFn: (chargeId: string) =>
bookingsService.acceptClearanceCharge(bookingId, chargeId),
onSuccess: (next) => {
qc.setQueryData(key, next);
toast.success("Accepted — your invoice is ready to pay");
},
onError,
});
const reject = useMutation({
mutationFn: (p: { chargeId: string; note: string }) =>
bookingsService.rejectClearanceCharge(bookingId, p.chargeId, p.note),
onSuccess: (next) => {
qc.setQueryData(key, next);
setRejecting(null);
setNote("");
toast.success("Sent back to Global Logistics");
},
onError,
});
const busy = accept.isPending || reject.isPending;
if (charges.length === 0) return null;
return (
<SectionCard id="clearance-charges">
<Group justify="space-between" align="center" mb="md">
<CardTitle>Clearance charges</CardTitle>
<Text fz="12.5px" fw={600} c="#9AA8B5">
{charges.length} {charges.length === 1 ? "charge" : "charges"}
</Text>
</Group>
<Stack gap={12}>
{charges.map((c) => {
const st = STATUS[c.status];
return (
<Box
key={c.id}
style={{ border: "1px solid #EEF2F6", borderRadius: 12, padding: "12px 14px" }}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Box style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Text fz="13.5px" fw={700} c="#10202F">
{LABEL[c.type]}
</Text>
<Box
style={{
padding: "3px 9px",
borderRadius: 999,
background: st.bg,
color: st.fg,
fontSize: 11,
fontWeight: 700,
whiteSpace: "nowrap",
}}
>
{st.label}
</Box>
</Group>
{c.description && (
<Text fz="12.5px" c="#6B7C8E" mt={4}>
{c.description}
</Text>
)}
{c.invoiceNumber && c.invoiceId && (
<Text fz="12px" c="#9AA8B5" mt={4}>
Invoice{" "}
<Link to={`/billing/${c.invoiceId}`} style={{ color: "#2E5B96" }}>
{c.invoiceNumber}
</Link>
</Text>
)}
</Box>
<Text fz="14px" fw={800} c="#10202F" style={{ whiteSpace: "nowrap" }}>
{money(c)}
</Text>
</Group>
{c.status === "REJECTED" && c.customerNote && (
<Alert color="red" variant="light" radius="md" p="xs" mt="sm">
<Text fz="12.5px">
You rejected this price: {c.customerNote}. Global Logistics
will revise it and send it again.
</Text>
</Alert>
)}
{c.status === "SENT" &&
(rejecting === c.id ? (
<Stack gap={6} mt="sm">
<Textarea
label="Why are you rejecting this charge?"
placeholder="Tell Global Logistics what is wrong with the price…"
minRows={2}
autosize
maxLength={1000}
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
/>
<Group gap="xs" justify="flex-end">
<Button
variant="default"
size="xs"
disabled={busy}
onClick={() => {
setRejecting(null);
setNote("");
}}
>
Cancel
</Button>
<Button
color="red"
size="xs"
loading={reject.isPending}
disabled={!note.trim()}
onClick={() => reject.mutate({ chargeId: c.id, note: note.trim() })}
>
Submit rejection
</Button>
</Group>
</Stack>
) : (
<Group gap="xs" justify="flex-end" mt="sm">
<Button
variant="default"
size="xs"
radius={10}
leftSection={<X size={14} />}
disabled={busy}
onClick={() => setRejecting(c.id)}
>
Reject
</Button>
<Button
color="edr-green"
size="xs"
radius={10}
leftSection={<Check size={14} />}
loading={accept.isPending}
disabled={busy}
onClick={() => accept.mutate(c.id)}
>
Accept price
</Button>
</Group>
))}
{c.status === "ACCEPTED" && c.invoiceId && (
<Group justify="flex-end" mt="sm">
<Button
size="xs"
radius={10}
color="edr-green"
leftSection={<CreditCard size={14} />}
onClick={() => setPayCharge(c)}
>
Pay
</Button>
</Group>
)}
{c.status === "PAID" && (
<Group gap={6} justify="flex-end" mt="sm">
<Receipt size={14} color="#0A6F4D" />
<Text fz="12px" c="#0A6F4D" fw={600}>
Paid{c.paidAt ? ` · ${new Date(c.paidAt).toLocaleString()}` : ""}
</Text>
</Group>
)}
</Box>
);
})}
</Stack>
<PaymentMethodModal
opened={payCharge !== null}
onClose={() => {
if (!pay.processing) {
setPayCharge(null);
pay.reset();
}
}}
amountLabel={payCharge ? money(payCharge) : undefined}
currency={payCharge?.currency}
onConfirm={(method, payerAccount) =>
payCharge?.invoiceId && pay.pay(payCharge.invoiceId, method, payerAccount)
}
processing={pay.processing}
error={pay.error}
otp={pay.otp}
bill={pay.bill}
/>
</SectionCard>
);
}

View File

@@ -0,0 +1,577 @@
import {
useQuery,
} from "@tanstack/react-query";
import {
Anchor,
Badge,
Box,
Button,
FileInput,
Group,
Paper,
Stack,
Text,
} from "@mantine/core";
import {
Check,
CheckCircle2,
Download,
Eye,
FileBadge,
Receipt,
Upload,
} from "lucide-react";
import {
useState,
} from "react";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import {
useFileViewer,
} from "@/hooks/useFileViewer";
import {
GREEN,
INK,
} from "@/pages/contracts/contract-ui";
import { bookingsService } from "@/services/bookings.service";
import { contractsService } from "@/services/contracts.service";
import { downloadStoredFile } from "@/services/files.service";
import { CardTitle, SectionCard } from "./layout";
// Customer-facing labels for an invoice status (Freight.InvoiceStatus).
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",
CANCELLED: "Cancelled",
REFUNDED: "Refunded",
EXPIRED: "Expired",
};
function invoiceStatusLabel(status: string): string {
return (
INVOICE_STATUS_LABELS[status] ??
status
.replace(/_/g, " ")
.toLowerCase()
.replace(/\b\w/g, (m) => m.toUpperCase())
);
}
/**
* Customs payments on a phased (customs) booking — duty / tax, the post-arrival
* additional duty and GL Djibouti's final invoice. All are paid by bank
* transfer; the customer attaches the slip here and Global Logistics confirms.
* Renders nothing until customs has advised something.
*/
export function CustomsPaymentsCard({ booking }: { booking: Freight.IBooking }) {
const isPhased = Boolean(booking.customsClearingEnabled && booking.contractId);
const { view, viewer } = useFileViewer();
const { data: clearance, refetch } = useQuery({
queryKey: ["booking-clearance", booking.id],
queryFn: () => bookingsService.getClearance(booking.id),
enabled: isPhased,
});
if (!isPhased || !clearance) return null;
const dutyPaid = Boolean(
clearance.milestones?.some(
(m) => m.milestoneCode === "DUTY_TAX_PAID" && m.status === "COMPLETED",
),
);
const showDuty = Boolean(clearance.dutyRequired && clearance.dutyAdvice);
const showSecond = Boolean(
clearance.secondDuty?.advised || clearance.secondDuty?.paid,
);
const showFinal = Boolean(clearance.finalInvoice);
if (!showDuty && !showSecond && !showFinal) return null;
const onChanged = () => void refetch();
return (
<SectionCard id="customs-payments">
<Group justify="space-between" align="center" mb="md">
<CardTitle>Customs payments</CardTitle>
<Text fz="12px" c="#9AA8B5">
Bank transfer · upload the slip here
</Text>
</Group>
<Stack gap="md">
{showDuty && clearance.dutyAdvice && (
dutyPaid ? (
<PaidRow
label="Customs duty & tax"
amount={clearance.dutyAdvice.amount}
currency={clearance.dutyAdvice.currency}
/>
) : (
<DutyAdvicePanel
dutyAdvice={clearance.dutyAdvice}
bookingId={booking.id}
onChanged={onChanged}
/>
)
)}
{showSecond && clearance.secondDuty && (
<SecondDutyDueCard
duty={clearance.secondDuty}
bookingId={booking.id}
onView={view}
onChanged={onChanged}
/>
)}
{showFinal && clearance.finalInvoice && (
<FinalInvoiceDueCard
invoice={clearance.finalInvoice}
bookingId={booking.id}
onView={view}
onChanged={onChanged}
/>
)}
</Stack>
{viewer}
</SectionCard>
);
}
/** A settled customs payment — slip uploaded, nothing left to do. */
function PaidRow({
label,
amount,
currency,
}: {
label: string;
amount: number;
currency: string;
}) {
return (
<Group
justify="space-between"
wrap="nowrap"
style={{
border: "1px solid #CDEBDD",
background: "#F6FBF8",
borderRadius: 12,
padding: "12px 14px",
}}
>
<Group gap={8} wrap="nowrap">
<CheckCircle2 size={16} color={GREEN} />
<Text fz="13.5px" fw={700} c={INK}>
{label}
</Text>
<Badge color="edr-green" variant="light" radius="sm">
Slip uploaded
</Badge>
</Group>
<Text fz="14px" fw={800} c={INK} style={{ whiteSpace: "nowrap" }}>
{amount.toLocaleString()} {currency}
</Text>
</Group>
);
}
function DutyAdvicePanel({
dutyAdvice,
bookingId,
onChanged,
}: {
dutyAdvice: NonNullable<Freight.ClearanceView["dutyAdvice"]>;
bookingId: string;
onChanged: () => void;
}) {
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const noticeFile = dutyAdvice.noticeFile;
return (
<Paper withBorder radius="md" p="md" bg="#FFFBF0">
<Stack gap="sm">
<GroupLabel icon={Receipt} text="Duty / tax payment" />
<Text size="sm">
Amount due:{" "}
<strong>
{dutyAdvice.amount.toLocaleString()} {dutyAdvice.currency}
</strong>
{dutyAdvice.declarationSerial
? ` · Payment code: ${dutyAdvice.declarationSerial}`
: null}
</Text>
{noticeFile ? (
<Anchor
component="button"
type="button"
onClick={() => void downloadStoredFile(noticeFile.id, noticeFile.name)}
size="sm"
>
<Group gap={6} wrap="nowrap">
<Download size={14} />
Download duty notice ({noticeFile.name})
</Group>
</Anchor>
) : null}
<Text size="sm" c="dimmed">
Pay the amount above, then upload your payment slip so clearance can continue.
</Text>
<FileInput label="Payment slip" value={file} onChange={setFile} size="sm" />
<Button
color="orange"
loading={loading}
disabled={!file}
leftSection={<Upload size={16} />}
onClick={async () => {
if (!file) return;
setLoading(true);
try {
await bookingsService.uploadBookingClearanceDutySlip(bookingId, file);
toast.success("Payment slip uploaded");
onChanged();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setLoading(false);
}
}}
>
Submit payment slip
</Button>
</Stack>
</Paper>
);
}
function GroupLabel({ icon: Icon, text }: { icon: typeof Receipt; text: string }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Icon size={16} />
<Text fw={600} size="sm">
{text}
</Text>
</div>
);
}
/**
* Post-offload final invoice from GL Djibouti (export): shows the due amount +
* invoice document; the customer pays offline and attaches the payment slip
* here, then GL confirms and the badge flips to PAID.
*/
function FinalInvoiceDueCard({
invoice,
bookingId,
onView,
onChanged,
}: {
invoice: NonNullable<Freight.ClearanceView["finalInvoice"]>;
bookingId: string;
onView: (file: { name: string; url: string }) => void;
onChanged: () => void;
}) {
const [slip, setSlip] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [approving, setApproving] = useState(false);
const paid = invoice.status === "PAID";
// GL Djibouti raises it as a draft: nothing is payable until the customer
// reviews the attached invoice and approves it.
const approved = Boolean(invoice.approvedAt);
return (
<Paper
withBorder
radius="lg"
p="lg"
style={{
borderColor: paid ? "#CDEBDD" : "#F2D9A6",
background: paid ? "#F6FBF8" : "#FFFBF2",
position: "relative",
overflow: "hidden",
}}
>
<Box
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 3,
background: paid ? GREEN : "#E3A93C",
}}
/>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<div>
<Group gap={8} align="center">
<FileBadge size={16} color={paid ? GREEN : "#B07C1F"} />
<Text fw={700} fz={15} c={INK}>
{paid
? "Final invoice paid"
: approved
? "Final invoice due"
: "Final invoice — your approval needed"}{" "}
{invoice.invoiceNumber}
</Text>
<Badge color={paid ? "edr-green" : "yellow"} variant="light" radius="sm">
{approved
? invoiceStatusLabel(invoice.status)
: "Awaiting your approval"}
</Badge>
</Group>
<Text fz={20} fw={800} mt={6} c={INK}>
{invoice.totalAmount.toLocaleString()} {invoice.currency}
</Text>
{invoice.description ? (
<Text fz={13} c="dimmed" mt={2}>
{invoice.description}
</Text>
) : null}
{!paid ? (
<Text fz={13} c="#9A6B1F" mt={6}>
{approved
? "Pay the amount above and attach your payment slip — Global Logistics will confirm the payment."
: "Review the invoice document from Global Logistics Djibouti and approve it to proceed with payment."}
</Text>
) : null}
</div>
<Stack gap="xs" miw={260}>
{invoice.invoiceFile ? (
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Eye size={15} />}
onClick={() =>
onView({
name: invoice.invoiceFile!.name,
url: invoice.invoiceFile!.url,
})
}
>
View invoice
</Button>
) : null}
{invoice.slipFile ? (
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Eye size={15} />}
onClick={() =>
onView({
name: invoice.slipFile!.name,
url: invoice.slipFile!.url,
})
}
>
View payment slip
</Button>
) : null}
{!paid && !approved ? (
<Button
color="edr-green"
radius="md"
size="sm"
loading={approving}
leftSection={<Check size={15} />}
onClick={async () => {
setApproving(true);
try {
await contractsService.approveFinalInvoice(bookingId);
toast.success("Invoice approved — you can now pay");
onChanged();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Approval failed");
} finally {
setApproving(false);
}
}}
>
Approve invoice
</Button>
) : null}
{!paid && approved ? (
<>
<FileInput
placeholder={
invoice.slipFile ? "Replace payment slip" : "Attach payment slip"
}
value={slip}
onChange={setSlip}
size="sm"
radius="md"
/>
<Button
color="edr-green"
radius="md"
size="sm"
loading={uploading}
disabled={!slip}
leftSection={<Upload size={15} />}
onClick={async () => {
if (!slip) return;
setUploading(true);
try {
await contractsService.uploadFinalInvoiceSlip(bookingId, slip);
setSlip(null);
toast.success("Payment slip attached");
onChanged();
} catch (e) {
toast.error(
e instanceof Error ? e.message : "Upload failed",
);
} finally {
setUploading(false);
}
}}
>
{invoice.slipFile ? "Replace slip" : "Submit payment slip"}
</Button>
</>
) : null}
</Stack>
</Group>
</Paper>
);
}
/**
* Post-arrival additional duty/tax round (import): GL advises an extra amount
* with a notice; the customer pays offline and attaches another slip here.
*/
function SecondDutyDueCard({
duty,
bookingId,
onView,
onChanged,
}: {
duty: NonNullable<Freight.ClearanceView["secondDuty"]>;
bookingId: string;
onView: (file: { name: string; url: string }) => void;
onChanged: () => void;
}) {
const [slip, setSlip] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const paid = duty.paid;
return (
<Paper
withBorder
radius="lg"
p="lg"
style={{
borderColor: paid ? "#CDEBDD" : "#F2D9A6",
background: paid ? "#F6FBF8" : "#FFFBF2",
position: "relative",
overflow: "hidden",
}}
>
<Box
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 3,
background: paid ? GREEN : "#E3A93C",
}}
/>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<div>
<Group gap={8} align="center">
<FileBadge size={16} color={paid ? GREEN : "#B07C1F"} />
<Text fw={700} fz={15} c={INK}>
{paid ? "Additional duty & tax paid" : "Additional duty & tax due"}
</Text>
<Badge color={paid ? "edr-green" : "yellow"} variant="light" radius="sm">
{paid ? "PAID" : "DUE"}
</Badge>
</Group>
<Text fz={20} fw={800} mt={6} c={INK}>
{(duty.amount ?? 0).toLocaleString()} {duty.currency ?? ""}
</Text>
{duty.declarationSerial ? (
<Text fz={13} c="dimmed" mt={2}>
Payment code: {duty.declarationSerial}
</Text>
) : null}
{!paid ? (
<Text fz={13} c="#9A6B1F" mt={6}>
Customs advised additional duty/tax after arrival. Pay the amount
above and attach your payment slip.
</Text>
) : null}
</div>
<Stack gap="xs" miw={260}>
{duty.noticeFile ? (
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Eye size={15} />}
onClick={() =>
onView({ name: duty.noticeFile!.name, url: duty.noticeFile!.url })
}
>
View duty notice
</Button>
) : null}
{duty.slipFile ? (
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Eye size={15} />}
onClick={() =>
onView({ name: duty.slipFile!.name, url: duty.slipFile!.url })
}
>
View payment slip
</Button>
) : null}
{!paid ? (
<>
<FileInput
placeholder={duty.slipFile ? "Replace payment slip" : "Attach payment slip"}
value={slip}
onChange={setSlip}
size="sm"
radius="md"
/>
<Button
color="edr-green"
radius="md"
size="sm"
loading={uploading}
disabled={!slip}
leftSection={<Upload size={15} />}
onClick={async () => {
if (!slip) return;
setUploading(true);
try {
await contractsService.uploadSecondDutySlip(bookingId, slip);
setSlip(null);
toast.success("Payment slip attached");
onChanged();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setUploading(false);
}
}}
>
{duty.slipFile ? "Replace slip" : "Submit payment slip"}
</Button>
</>
) : null}
</Stack>
</Group>
</Paper>
);
}

View File

@@ -0,0 +1,269 @@
import { Box, Button, Group, Stack, Text, UnstyledButton } from "@mantine/core";
import {
ArrowRight,
CheckCircle2,
CreditCard,
FileCheck2,
Landmark,
Scale,
Upload,
type LucideIcon,
} from "lucide-react";
import type { Freight } from "@edr/types";
import {
isReviewAction,
useBookingPayables,
type PayableAction,
type PayableItem,
} from "@/pages/bookings/payments/useBookingPayables";
import type { useBookingPayment } from "@/pages/bookings/payments/useBookingPayment";
import { formatAmount } from "../utils";
import { BookingPaymentPanel } from "./BookingPaymentPanel";
import { ClearanceChargesSection } from "./ClearanceChargesSection";
import { CustomsPaymentsCard } from "./CustomsPaymentsCard";
import { BodyGrid, CardTitle, SectionCard } from "./layout";
import { WagonCancellationCard } from "./WagonCancellationCard";
const ACTION_META: Record<
PayableAction,
{ verb: string; icon: LucideIcon; color: string }
> = {
PAY: { verb: "Pay now", icon: CreditCard, color: "#0A6F4D" },
BANK_TRANSFER: { verb: "Bank transfer", icon: Landmark, color: "#B07D14" },
UPLOAD_SLIP: { verb: "Upload slip", icon: Upload, color: "#B07D14" },
APPROVE: { verb: "Approve", icon: FileCheck2, color: "#2E5B96" },
DECIDE: { verb: "Accept or reject", icon: Scale, color: "#2E5B96" },
};
const money = (amount: number, currency: string) =>
`${formatAmount(amount)} ${currency}`.trim();
const scrollTo = (id: string) =>
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
/**
* The booking's Payments tab — every amount the customer owes or must decide
* on, in one place: freight (with its pay window), clearance charges to
* accept and pay, customs duty / final invoice slips, wagon-cancellation fees.
* The summary strip at the top lists what is outstanding and jumps to the card
* that settles it.
*/
export function PaymentsTab({
booking,
pay,
showCountdown,
onBookingUpdated,
}: {
booking: Freight.IBooking;
pay: ReturnType<typeof useBookingPayment>;
showCountdown: boolean;
onBookingUpdated?: () => void;
}) {
const { items, dueTotals, loading } = useBookingPayables(booking);
return (
<Stack gap="lg">
<PaymentsSummary items={items} dueTotals={dueTotals} loading={loading} />
<BodyGrid
left={
<>
<ClearanceChargesSection bookingId={booking.id} />
<CustomsPaymentsCard booking={booking} />
<WagonCancellationCard
booking={booking}
onBookingUpdated={onBookingUpdated}
/>
</>
}
right={
<BookingPaymentPanel
booking={booking}
pricing={booking.pricingBreakdown}
onPay={pay.open}
paying={pay.processing}
showCountdown={showCountdown}
/>
}
/>
</Stack>
);
}
function PaymentsSummary({
items,
dueTotals,
loading,
}: {
items: PayableItem[];
dueTotals: Array<{ currency: string; amount: number }>;
loading: boolean;
}) {
const reviews = items.filter((i) => isReviewAction(i.action)).length;
const settled = !loading && items.length === 0;
return (
<SectionCard
p={22}
style={{
background: settled ? "#F6FBF8" : "#FFFDF7",
borderColor: settled ? "#CDEBDD" : "#F3E2B8",
}}
>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
<Box style={{ minWidth: 220 }}>
<CardTitle>{settled ? "All settled" : "Amount due"}</CardTitle>
{loading ? (
<Text fz="14px" c="#9AA8B5" mt={8}>
Checking your payments
</Text>
) : settled ? (
<Group gap={8} mt={8} wrap="nowrap">
<CheckCircle2 size={20} color="#0A6F4D" />
<Text fz="18px" fw={800} c="#10202F">
Nothing to pay right now
</Text>
</Group>
) : (
<>
<Group gap={18} mt={6} align="baseline">
{dueTotals.length > 0 ? (
dueTotals.map((t) => (
<Text key={t.currency} fz="28px" fw={800} c="#10202F" lh={1.1}>
{money(t.amount, t.currency)}
</Text>
))
) : (
<Text fz="20px" fw={800} c="#10202F">
Your review is needed
</Text>
)}
</Group>
<Text fz="12.5px" c="#6B7C8E" mt={6}>
{items.length} {items.length === 1 ? "item needs" : "items need"} your
attention
{reviews > 0 ? ` · ${reviews} awaiting your review` : ""}
</Text>
</>
)}
</Box>
{!settled && !loading && (
<Stack gap={6} style={{ flex: 1, minWidth: 280, maxWidth: 480 }}>
{items.map((it) => {
const m = ACTION_META[it.action];
const Icon = m.icon;
return (
<UnstyledButton
key={`${it.anchor}-${it.id}`}
onClick={() => scrollTo(it.anchor)}
style={{
border: "1px solid #EEF2F6",
borderRadius: 10,
padding: "8px 12px",
background: "white",
}}
>
<Group justify="space-between" wrap="nowrap" gap={10}>
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<Icon size={14} color={m.color} />
<Box style={{ minWidth: 0 }}>
<Text fz="13px" fw={700} c="#10202F" truncate>
{it.label}
</Text>
{it.detail && (
<Text fz="11.5px" c="#9AA8B5" truncate>
{it.detail}
</Text>
)}
</Box>
</Group>
<Group gap={8} wrap="nowrap">
<Text
fz="13px"
fw={800}
c="#10202F"
style={{ whiteSpace: "nowrap" }}
>
{money(it.amount, it.currency)}
</Text>
<Text
fz="11.5px"
fw={700}
c={m.color}
style={{ whiteSpace: "nowrap" }}
>
{m.verb}
</Text>
<ArrowRight size={13} color="#9AA8B5" />
</Group>
</Group>
</UnstyledButton>
);
})}
</Stack>
)}
</Group>
</SectionCard>
);
}
/**
* Compact "amount due" strip on the Overview tab — the only payment surface
* left there. Renders nothing when the booking has nothing outstanding.
*/
export function PaymentsDueStrip({
booking,
onOpen,
}: {
booking: Freight.IBooking;
onOpen: () => void;
}) {
const { items, dueTotals } = useBookingPayables(booking);
if (items.length === 0) return null;
const labels = [...new Set(items.map((i) => i.label))].join(", ");
return (
<SectionCard
p="md"
style={{ background: "#FFFDF7", borderColor: "#F3E2B8" }}
>
<Group justify="space-between" wrap="wrap" gap="md">
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<Box
style={{
width: 40,
height: 40,
borderRadius: 12,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#FEF3E2",
color: "#B45309",
}}
>
<CreditCard size={18} />
</Box>
<Box style={{ minWidth: 0 }}>
<Text fz="14px" fw={800} c="#10202F">
{dueTotals.length > 0
? `${dueTotals.map((t) => money(t.amount, t.currency)).join(" + ")} due`
: "A payment needs your review"}
</Text>
<Text fz="12.5px" c="#6B7C8E" truncate>
{items.length} {items.length === 1 ? "item" : "items"}: {labels}
</Text>
</Box>
</Group>
<Button
color="edr-green"
radius={10}
rightSection={<ArrowRight size={15} />}
onClick={onOpen}
>
Go to payments
</Button>
</Group>
</SectionCard>
);
}

View File

@@ -232,7 +232,7 @@ export function WagonCancellationCard({
if (!canRequest && !ownRows.length) return null;
return (
<SectionCard>
<SectionCard id="wagon-cancellation">
<Group justify="space-between" align="center" mb="sm">
<CardTitle>Wagon Cancellation</CardTitle>
{/* {canRequest && !openRow && !creditRow && (

View File

@@ -34,8 +34,8 @@ import {
} from "lucide-react";
import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
import { PayNowButton } from "./payments/PayNowButton";
import { payWindowState } from "./payments/payment-drain";
import { PayButton } from "./payments/PayButton";
import { useMyPayables } from "./payments/useMyPayables";
import { BookingActionButton } from "./clearance/BookingActionButton";
import { bookingHasInlineAction } from "./clearance/bookingNextAction";
import {
@@ -181,11 +181,14 @@ const STAT_CARDS: Array<{
function PrimaryAction({
booking,
credit,
payable,
onNavigate,
}: {
booking: Freight.IBooking;
/** CREDIT_AVAILABLE wagon cancellation opened by this booking, if any. */
credit?: WagonCancellation;
/** Outstanding payments on this booking (from `my-payables`), if any. */
payable?: Freight.BookingPayableSummary;
onNavigate: (path: string) => void;
}) {
const { status, id } = booking;
@@ -199,9 +202,6 @@ function PrimaryAction({
/>
);
}
// A general contract is payable as soon as it's FULLY_EXECUTED (signed); a
// one-time booking only after it's SELECTED_FOR_BATCH.
const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT";
if (status === "DRAFT") {
return (
<Button
@@ -220,24 +220,16 @@ function PrimaryAction({
</Button>
);
}
// Anything outstanding (freight, clearance charge, duty slip, cancellation
// fee) → "Pay" jumps to the booking's Payments tab.
if (payable) {
return <PayButton bookingId={id} summary={payable} />;
}
// CHANGES_REQUESTED + clearance/operation steps are handled in place by a
// modal (update & resubmit, upload clearance docs, schedule & proceed).
if (bookingHasInlineAction(booking)) {
return <BookingActionButton booking={booking} size="xs" />;
}
const payableStatus = isGeneralContract
? "FULLY_EXECUTED"
: "SELECTED_FOR_BATCH";
// A fully-closed pay window (deadline + drain both elapsed) falls through to
// the default action. The drain itself still routes here — PayNowButton
// renders the "payment processing" wait notice instead of a pay action.
if (
status === payableStatus &&
booking.paymentStatus !== "PAID" &&
payWindowState(booking).phase !== "closed"
) {
return <PayNowButton booking={booking} />;
}
// Contract ready for the customer's signature → full-page contract viewer.
if (bookingIsSignable(booking)) {
return <ContractSignButton booking={booking} size="xs" />;
@@ -460,6 +452,8 @@ export default function BookingsListPage() {
input: { pageSize: 100 },
}),
);
// Outstanding payments per booking → row "Pay" button (one shared query).
const payables = useMyPayables();
const creditByBooking = useMemo(() => {
const m = new Map<string, WagonCancellation>();
for (const r of myCancellations?.items ?? []) {
@@ -702,6 +696,7 @@ export default function BookingsListPage() {
<PrimaryAction
booking={booking}
credit={creditByBooking.get(booking.id)}
payable={payables.get(booking.id)}
onNavigate={navigate}
/>
<Menu position="bottom-end" withinPortal shadow="md" radius="md">

View File

@@ -39,7 +39,7 @@ interface BookingActionButtonProps {
* when the booking has no customer-actionable clearance/operation step;
* otherwise shows a button that opens the in-place {@link BookingActionModal}.
*
* Drop it into a list row exactly like {@link PayNowButton} — it stops click
* Drop it into a list row exactly like {@link PayButton} — it stops click
* propagation so it never triggers the row's navigation handler.
*/
export function BookingActionButton({

View File

@@ -28,7 +28,7 @@ interface ContractSignButtonProps {
* that navigates to the full-page contract viewer ({@link BookingContractPage})
* where the signature flow lives.
*
* Drop it into a list row exactly like {@link PayNowButton} — it stops click
* Drop it into a list row exactly like {@link PayButton} — it stops click
* propagation so it never triggers the row's navigation handler.
*/
export function ContractSignButton({

View File

@@ -0,0 +1,55 @@
import { Button, type ButtonProps } from "@mantine/core";
import { CreditCard } from "lucide-react";
import { useNavigate } from "react-router-dom";
import type { Freight } from "@edr/types";
import { formatAmount } from "../BookingDetailPage/utils";
/** The booking detail page opened on its Payments tab. */
export const paymentsTabPath = (bookingId: string) =>
`/bookings/${bookingId}?tab=payments`;
/**
* "Pay" on a list / home row. Every payable item (freight, clearance charges,
* customs duty, cancellation fees) lives on the booking's Payments tab, so the
* row only needs to get the customer there — no per-row payment modal.
*/
export function PayButton({
bookingId,
summary,
size = "xs",
fullWidth,
}: {
bookingId: string;
summary?: Freight.BookingPayableSummary;
size?: ButtonProps["size"];
fullWidth?: boolean;
}) {
const navigate = useNavigate();
const single = summary?.totals.length === 1 ? summary.totals[0] : null;
// Only items awaiting the customer's review (a proposed price, a draft
// final invoice): nothing to pay yet, but still theirs to act on.
const reviewOnly = summary != null && summary.totals.length === 0;
return (
<Button
size={size}
radius="md"
fw={700}
fz={13}
color="edr-green"
fullWidth={fullWidth}
leftSection={<CreditCard size={14} />}
onClick={(e) => {
// Don't let a surrounding row-click handler fire.
e.stopPropagation();
navigate(paymentsTabPath(bookingId));
}}
>
{reviewOnly
? "Review payment"
: single
? `Pay ${formatAmount(single.amount)} ${single.currency}`
: "Pay"}
</Button>
);
}

View File

@@ -1,102 +0,0 @@
import { Badge, Button, type ButtonProps } from "@mantine/core";
import { CreditCard, Landmark } from "lucide-react";
import { Freight } from "@edr/types";
import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper";
import { PaymentMethodModal } from "../BookingDetailPage/components/PaymentMethodModal";
import { priceTotal } from "../BookingDetailPage/utils";
import { isUsdOfflineBooking } from "./offline-payment";
import { payWindowState } from "./payment-drain";
import { PaymentProcessingNotice } from "./PaymentProcessingNotice";
import { useBookingPayment } from "./useBookingPayment";
interface PayNowButtonProps {
booking: Freight.IBooking;
label?: string;
size?: ButtonProps["size"];
fullWidth?: boolean;
}
/**
* Self-contained "Pay now" action: shows the payment-method modal in place
* instead of navigating to the booking detail page. Drop it into list rows,
* cards, or anywhere a payable booking surfaces.
*/
export function PayNowButton({
booking,
label = "Pay now",
size = "xs",
fullWidth,
}: PayNowButtonProps) {
const pay = useBookingPayment(booking.id);
const pricing = booking.pricingBreakdown;
const payWindow = payWindowState(booking);
// Pay deadline passed but in-flight payments are still settling: show the
// drain countdown instead of any pay action, so nobody pays a second time.
// Checked before the USD branch — a bank transfer is just as double-payable.
if (payWindow.phase === "draining" && payWindow.drainEndsAt) {
return (
<PaymentProcessingNotice
drainEndsAt={payWindow.drainEndsAt}
variant="inline"
/>
);
}
// Window fully over (drain included) — nothing to pay against anymore.
if (payWindow.phase === "closed") {
return null;
}
// USD is paid by bank transfer and confirmed by Finance — no online payment.
if (isUsdOfflineBooking(booking)) {
return (
<Badge
size={size === "xs" ? "md" : "lg"}
radius="md"
variant="light"
color="yellow"
fullWidth={fullWidth}
leftSection={<Landmark size={12} />}
styles={{ label: { textTransform: "none", fontWeight: 700 } }}
>
Pay by bank transfer
</Badge>
);
}
return (
<ModalSafeWrapper>
<Button
size={size}
radius="md"
fw={700}
fz={13}
color="edr-green"
fullWidth={fullWidth}
leftSection={<CreditCard size={14} />}
onClick={(e) => {
// Don't let a surrounding row-click handler fire.
e.stopPropagation();
pay.open();
}}
>
{label}
</Button>
<PaymentMethodModal
opened={pay.modalOpen}
onClose={pay.close}
amountLabel={pricing ? priceTotal(pricing) : undefined}
currency={pricing?.currency ?? booking.paymentCurrency}
processing={pay.processing}
error={pay.error}
otp={pay.otp}
bill={pay.bill}
onConfirm={pay.confirm}
/>
</ModalSafeWrapper>
);
}

View File

@@ -0,0 +1,205 @@
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { Freight } from "@edr/types";
import { isPayable } from "@/pages/billing/invoice-ui";
import { bookingsService } from "@/services/bookings.service";
import { invoicesService } from "@/services/invoices.service";
import { isUsdOfflineBooking } from "./offline-payment";
import { WAGON_CANCEL_FEE_INVOICE_TYPE } from "./useBookingPayment";
export type PayableAction =
| "PAY"
| "BANK_TRANSFER"
| "UPLOAD_SLIP"
| "APPROVE"
| "DECIDE";
export interface PayableItem {
id: string;
label: string;
detail?: string | null;
amount: number;
currency: string;
/** What the customer must do with it. */
action: PayableAction;
/** DOM id of the Payments-tab card that handles it. */
anchor: string;
}
/** Card ids on the Payments tab — the summary strip scrolls to these. */
export const PAYABLE_ANCHORS = {
freight: "freight-payment",
charges: "clearance-charges",
customs: "customs-payments",
wagons: "wagon-cancellation",
} as const;
/** Booking statuses at which the freight invoice is actually due (mirrors the API). */
const FREIGHT_PAYABLE_STATUSES = new Set([
"FULLY_EXECUTED",
"SELECTED_FOR_BATCH",
"AWAITING_PAYMENT",
]);
const CHARGE_LABEL: Record<Freight.ClearanceChargeType, string> = {
PORT_CHARGES: "Port charges",
MISCELLANEOUS: "Miscellaneous charge",
};
/** Items the customer still has to review before anything is payable. */
export const isReviewAction = (a: PayableAction) =>
a === "DECIDE" || a === "APPROVE";
/**
* Everything the customer still owes or must decide on for one booking,
* assembled from the same queries the Payments-tab cards use (shared keys, so
* no extra requests): freight + wagon-fee + final invoices, clearance charges,
* customs duty advices. Mirrors the server's `my-payables` rule set.
*/
export function useBookingPayables(booking: Freight.IBooking) {
const isPhased = Boolean(booking.customsClearingEnabled && booking.contractId);
const invoicesQ = useQuery({
queryKey: ["booking-invoices", booking.id],
queryFn: () => invoicesService.listForSource("booking", booking.id),
});
const chargesQ = useQuery({
queryKey: ["booking-clearance-charges", booking.id],
queryFn: () => bookingsService.getClearanceCharges(booking.id),
});
const clearanceQ = useQuery({
queryKey: ["booking-clearance", booking.id],
queryFn: () => bookingsService.getClearance(booking.id),
enabled: isPhased,
});
const items = useMemo(() => {
const out: PayableItem[] = [];
const offline = isUsdOfflineBooking(booking);
for (const inv of invoicesQ.data ?? []) {
const balance = Number(inv.balanceAmount ?? 0);
if (inv.type === Freight.GL_FINAL_INVOICE_TYPE) {
// Raised as DRAFT; issuing IS the customer's approval, then slip-paid.
if (inv.status === Freight.InvoiceStatus.Draft) {
out.push({
id: inv.id,
label: "Final invoice",
detail: `${inv.invoiceNumber} · approve to proceed`,
amount: Number(inv.totalAmount),
currency: inv.currency,
action: "APPROVE",
anchor: PAYABLE_ANCHORS.customs,
});
} else if (isPayable(inv.status) && balance > 0) {
out.push({
id: inv.id,
label: "Final invoice",
detail: inv.invoiceNumber,
amount: balance,
currency: inv.currency,
action: "UPLOAD_SLIP",
anchor: PAYABLE_ANCHORS.customs,
});
}
continue;
}
if (!isPayable(inv.status) || balance <= 0) continue;
if (inv.type === WAGON_CANCEL_FEE_INVOICE_TYPE) {
out.push({
id: inv.id,
label: "Wagon cancellation fee",
detail: inv.invoiceNumber,
amount: balance,
currency: inv.currency,
action: "PAY",
anchor: PAYABLE_ANCHORS.wagons,
});
continue;
}
if (
booking.paymentStatus !== "PAID" &&
FREIGHT_PAYABLE_STATUSES.has(booking.status as string)
) {
out.push({
id: inv.id,
label: "Freight",
detail: inv.invoiceNumber,
amount: balance,
currency: inv.currency,
action: offline ? "BANK_TRANSFER" : "PAY",
anchor: PAYABLE_ANCHORS.freight,
});
}
}
for (const c of chargesQ.data ?? []) {
if (c.status !== "SENT" && c.status !== "ACCEPTED") continue;
out.push({
id: c.id,
label: CHARGE_LABEL[c.type],
detail: c.status === "SENT" ? c.description : c.invoiceNumber,
amount: c.amount ?? 0,
currency: c.currency ?? "",
action: c.status === "SENT" ? "DECIDE" : "PAY",
anchor: PAYABLE_ANCHORS.charges,
});
}
const cl = clearanceQ.data;
if (cl) {
const dutyPaid = cl.milestones?.some(
(m) => m.milestoneCode === "DUTY_TAX_PAID" && m.status === "COMPLETED",
);
if (cl.dutyRequired && cl.dutyAdvice && !dutyPaid) {
out.push({
id: "duty",
label: "Customs duty & tax",
detail: cl.dutyAdvice.declarationSerial
? `Payment code ${cl.dutyAdvice.declarationSerial}`
: null,
amount: cl.dutyAdvice.amount,
currency: cl.dutyAdvice.currency,
action: "UPLOAD_SLIP",
anchor: PAYABLE_ANCHORS.customs,
});
}
if (cl.secondDuty?.advised && !cl.secondDuty.paid) {
out.push({
id: "second-duty",
label: "Additional duty & tax",
detail: cl.secondDuty.declarationSerial
? `Payment code ${cl.secondDuty.declarationSerial}`
: null,
amount: cl.secondDuty.amount ?? 0,
currency: cl.secondDuty.currency ?? "",
action: "UPLOAD_SLIP",
anchor: PAYABLE_ANCHORS.customs,
});
}
}
return out;
}, [booking, invoicesQ.data, chargesQ.data, clearanceQ.data]);
// Payable now, per currency. Items still under review are not "due" yet.
const dueTotals = useMemo(() => {
const m = new Map<string, number>();
for (const it of items) {
if (isReviewAction(it.action) || !it.currency) continue;
m.set(it.currency, (m.get(it.currency) ?? 0) + it.amount);
}
return [...m.entries()].map(([currency, amount]) => ({ currency, amount }));
}, [items]);
return {
items,
dueTotals,
reviewCount: items.filter((i) => isReviewAction(i.action)).length,
loading:
invoicesQ.isPending ||
chargesQ.isPending ||
(isPhased && clearanceQ.isPending),
};
}

View File

@@ -0,0 +1,26 @@
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import type { Freight } from "@edr/types";
import { bookingsService } from "@/services/bookings.service";
export const MY_PAYABLES_KEY = ["my-payables"] as const;
/**
* Outstanding payments for every booking of the signed-in company, keyed by
* booking id. One request shared by every row on the home page and the
* bookings list (react-query dedupes by key), so rows can show "Pay" without
* each resolving their own invoices.
*/
export function useMyPayables(): Map<string, Freight.BookingPayableSummary> {
const { data } = useQuery({
queryKey: MY_PAYABLES_KEY,
queryFn: bookingsService.getMyPayables,
staleTime: 30_000,
refetchOnWindowFocus: true,
});
return useMemo(
() => new Map((data ?? []).map((p) => [p.bookingId, p] as const)),
[data],
);
}

View File

@@ -48,7 +48,9 @@ function serviceFeatures(s: ServiceItem) {
{
key: "customs",
icon: ShieldCheck,
label: "Customs clearance",
label: s.includesEthiopianCustomsOnly
? "Ethiopian customs clearance"
: "Customs clearance",
on: s.includesCustoms,
},
];

View File

@@ -533,6 +533,40 @@ export const bookingsService = {
return data.data ?? data;
},
/** Outstanding payments per booking — drives the "Pay" badge on list/home rows. */
getMyPayables: async (): Promise<Freight.BookingPayableSummary[]> => {
const { data } = await client.get(`/api/bookings/my-payables`);
return data.data ?? data;
},
// ── Clearance charges (port + miscellaneous) the customer approves, then pays ──
getClearanceCharges: async (id: string): Promise<Freight.ClearanceCharge[]> => {
const { data } = await client.get(`/api/bookings/${id}/clearance/charges`);
return data.data ?? data;
},
acceptClearanceCharge: async (
id: string,
chargeId: string,
): Promise<Freight.ClearanceCharge[]> => {
const { data } = await client.post(
`/api/bookings/${id}/clearance/charges/${chargeId}/accept`,
);
return data.data ?? data;
},
rejectClearanceCharge: async (
id: string,
chargeId: string,
note: string,
): Promise<Freight.ClearanceCharge[]> => {
const { data } = await client.post(
`/api/bookings/${id}/clearance/charges/${chargeId}/reject`,
{ note },
);
return data.data ?? data;
},
acceptDraftDeclaration: async (id: string): Promise<Freight.IBooking> => {
const { data } = await client.post(
`/api/bookings/${id}/clearance/draft-declaration/accept`,