mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
[200~feat: add CustomsPaymentsCard and PaymentsTab components for handling customs payments and payment summaries
This commit is contained in:
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user