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) => ( )} ) : 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} /> v && setCurrency(v)} w={100} /> ); }