mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-06 12:25:02 +00:00
[200~feat: add CustomsPaymentsCard and PaymentsTab components for handling customs payments and payment summaries
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user