import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Alert,
Badge,
Box,
Button,
FileButton,
Group,
Loader,
NumberInput,
Paper,
Select,
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import {
CheckCircle2,
Download,
Eye,
FileText,
Lock,
Receipt,
Send,
Upload,
XCircle,
} from "lucide-react";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
import { bookingsService } from "@/services/bookings.service";
import {
downloadBookingFile,
fetchViewableFile,
} from "@/services/files.service";
import { formatDateTime } from "@/lib/format";
import { extractErrorMessage } from "@/utils/errorExtractor";
const CURRENCIES = ["ETB", "USD"];
const STATUS_META: Record<
Freight.ClearanceChargeStatus,
{ label: string; color: string }
> = {
DOC_UPLOADED: { label: "Awaiting billing", color: "yellow" },
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. */
roleMode: "ET" | "DJ";
onViewFile: (file: { name: string; url: string }) => void;
}
/**
* 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,
roleMode,
onViewFile,
}: ClearanceChargesTabProps) {
const qc = useQueryClient();
// Bumped after each create so the form remounts empty for the next charge.
const [miscCreated, setMiscCreated] = useState(0);
const { data: charges, isLoading } = useQuery({
queryKey: ["clearance-charges", bookingId],
queryFn: () => bookingsService.getClearanceCharges(bookingId),
});
const refresh = (next: Freight.ClearanceCharge[]) =>
qc.setQueryData(["clearance-charges", bookingId], next);
const onError = (e: unknown) =>
toast.error(extractErrorMessage(e, "Could not update the charge"));
const uploadPort = useMutation({
mutationFn: (file: File) =>
bookingsService.uploadPortChargeDocument(bookingId, file),
onSuccess: (next) => {
toast.success("Port-charges document uploaded");
refresh(next);
},
onError,
});
const bill = useMutation({
// Body must be exactly the DTO — the API rejects unknown keys like chargeId.
mutationFn: ({ chargeId, ...payload }: BillInput & { chargeId: string }) =>
bookingsService.billClearanceCharge(bookingId, chargeId, payload),
onSuccess: (next) => {
toast.success("Charge amount saved");
refresh(next);
},
onError,
});
const send = useMutation({
mutationFn: (chargeId: string) =>
bookingsService.sendClearanceCharge(bookingId, chargeId),
onSuccess: (next) => {
toast.success("Sent to the customer for approval");
refresh(next);
},
onError,
});
const createMisc = useMutation({
mutationFn: ({ file, ...payload }: BillInput & { file: File }) =>
bookingsService.createMiscellaneousCharge(bookingId, file, payload),
onSuccess: (next) => {
toast.success("Miscellaneous charge created");
// Remount the form so the next charge starts from an empty one.
setMiscCreated((n) => n + 1);
refresh(next);
},
onError,
});
if (isLoading) {
return (
Loading charges…
);
}
const port = (charges ?? []).find((c) => c.type === "PORT_CHARGES") ?? null;
const miscCharges = (charges ?? []).filter(
(c) => c.type === "MISCELLANEOUS",
);
const busy =
uploadPort.isPending || bill.isPending || send.isPending || createMisc.isPending;
const totals = new Map();
for (const c of charges ?? []) {
if (c.amount != null && c.currency)
totals.set(c.currency, (totals.get(c.currency) ?? 0) + c.amount);
}
return (
port && bill.mutate({ chargeId: port.id, ...input })}
onSend={() => port && send.mutate(port.id)}
djUpload={
roleMode === "DJ" && (!port || port.status === "DOC_UPLOADED") ? (
f && uploadPort.mutate(f)}
accept="application/pdf,image/*"
disabled={busy}
>
{(props) => (
}
loading={uploadPort.isPending}
>
{port ? "Replace document" : "Upload document"}
)}
) : null
}
/>
{/* Any number of miscellaneous charges, in any order relative to the
port charge — each is billed and paid on its own. */}
{miscCharges.map((c, i) => (
1
? `Miscellaneous charge ${i + 1}`
: "Miscellaneous charge"
}
charge={c}
roleMode={roleMode}
busy={busy}
emptyHint=""
onViewFile={onViewFile}
onBill={(input) => bill.mutate({ chargeId: c.id, ...input })}
onSend={() => send.mutate(c.id)}
/>
))}
{roleMode === "ET" && (
{miscCharges.length > 0
? "Add another miscellaneous charge"
: "Add a miscellaneous charge"}
Upload the supporting document, set the amount and say what it is
for. The customer sees it once you send it for approval.
createMisc.mutate({ file, ...input })}
/>
)}
{totals.size > 0 && (
Total billed
{[...totals.entries()].map(([currency, amount]) => (
{amount.toLocaleString(undefined, {
minimumFractionDigits: 2,
})}{" "}
{currency}
))}
)}
);
}
function ChargeCard({
title,
charge,
roleMode,
busy,
emptyHint,
onViewFile,
onBill,
onSend,
djUpload,
etCreate,
}: {
title: string;
charge: Freight.ClearanceCharge | null;
roleMode: "ET" | "DJ";
busy: boolean;
emptyHint: string;
onViewFile: (file: { name: string; url: string }) => void;
onBill: (input: BillInput) => void;
onSend: () => void;
djUpload?: React.ReactNode;
etCreate?: React.ReactNode;
}) {
const [editing, setEditing] = useState(false);
const [amount, setAmount] = useState(charge?.amount ?? "");
const [currency, setCurrency] = useState(charge?.currency ?? "ETB");
const [description, setDescription] = useState(charge?.description ?? "");
const status = charge?.status ?? null;
const meta = status ? STATUS_META[status] : null;
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 (
{title}
{charge?.uploadedAt && (
Document uploaded
{charge.uploadedByName ? ` by ${charge.uploadedByName}` : ""} ·{" "}
{formatDateTime(charge.uploadedAt)}
)}
{charge?.billedAt && (
Billed{charge.billedByName ? ` by ${charge.billedByName}` : ""} ·{" "}
{formatDateTime(charge.billedAt)}
)}
{charge?.status === "ACCEPTED" && charge.customerDecidedAt && (
Accepted by the customer · {formatDateTime(charge.customerDecidedAt)}
{charge.invoiceNumber ? ` (invoice ${charge.invoiceNumber})` : ""}
)}
{charge?.paidAt && (
Paid · {formatDateTime(charge.paidAt)}
{charge.invoiceNumber ? ` (invoice ${charge.invoiceNumber})` : ""}
)}
{charge?.amount != null && charge.currency && (
{charge.amount.toLocaleString(undefined, {
minimumFractionDigits: 2,
})}{" "}
{charge.currency}
)}
{meta && (
{meta.label}
)}
{charge?.file && (
{charge.file.name}
{isViewable({ name: charge.file.name, url: "" }) && (
void fetchViewableFile(
charge.file!.id,
charge.file!.name,
).then(onViewFile)
}
c="edr-green"
style={{
display: "flex",
background: "transparent",
border: "none",
cursor: "pointer",
}}
>
)}
void downloadBookingFile(charge.file!.id, charge.file!.name)
}
c="edr-green"
style={{
display: "flex",
background: "transparent",
border: "none",
cursor: "pointer",
}}
>
)}
{charge?.description && !showBillForm && (
{charge.description}
)}
{charge?.status === "REJECTED" && charge.customerNote && (
}
title="Rejected by the customer"
>
{charge.customerNote}
{charge.customerDecidedAt && (
{formatDateTime(charge.customerDecidedAt)} — fix the price or
description and send it again.
)}
)}
{!charge && (
{emptyHint}
)}
{djUpload && {djUpload}}
{etCreate && {etCreate}}
{showBillForm && (
setDescription(e.currentTarget.value)}
maxLength={1000}
w={320}
/>
)}
{roleMode === "ET" && charge && !showBillForm && !locked && (
{(charge.status === "BILLED" || charge.status === "REJECTED") && (
}
disabled={busy}
onClick={onSend}
>
{charge.status === "REJECTED"
? "Send again for approval"
: "Send to customer for approval"}
)}
)}
{charge?.status === "ACCEPTED" && (
Locked — invoice {charge.invoiceNumber ?? ""} awaiting payment
)}
{charge?.status === "PAID" && (
Settled
)}
);
}
function MiscCreateForm({
busy,
onCreate,
}: {
busy: boolean;
onCreate: (file: File, input: BillInput) => void;
}) {
const [file, setFile] = useState(null);
const [amount, setAmount] = useState("");
const [currency, setCurrency] = useState("ETB");
const [description, setDescription] = useState("");
return (
setDescription(e.currentTarget.value)}
maxLength={1000}
w={320}
/>
{(props) => (
}
>
{file ? file.name : "Choose document"}
)}
);
}