[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
parent 8d7551bb8e
commit 9e1d5ee9f2
58 changed files with 3324 additions and 810 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