[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

@@ -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`,