Enhance manual payment processing for USD and ETB invoices

- Updated API documentation and summaries to reflect support for both USD and ETB invoices.
- Modified data structures to include trade direction for invoices.
- Adjusted UI components to accommodate manual payment confirmations and display relevant information.
- Implemented filtering options for currency in the manual payments worklist.
This commit is contained in:
Marshal
2026-08-17 09:13:09 +00:00
parent df488ebfaa
commit 1ca7776143
9 changed files with 242 additions and 78 deletions

View File

@@ -315,7 +315,7 @@ const App = () => {
}
/>
{/* Merged Invoices / Payments / USD Payments hub — tabs switch via
?tab=invoices|payments|usd-payments (default invoices). Access is
?tab=invoices|payments|manual-payments (default invoices). Access is
OR'd across both keys so a user with just one still gets in; each
tab hides itself if the user lacks the permission it used to be
routed on. */}
@@ -352,7 +352,7 @@ const App = () => {
/>
<Route
path="usd-payments"
element={<Navigate to="/dashboard/invoices?tab=usd-payments" replace />}
element={<Navigate to="/dashboard/invoices?tab=manual-payments" replace />}
/>
<Route
path="invoices/:id"

View File

@@ -29,13 +29,13 @@ const TABS = [
Panel: InvoicesPanel,
},
{
key: "usd-payments",
label: "USD Payments",
key: "manual-payments",
label: "Manual Payments",
icon: Landmark,
// Same gate as Invoices, not a dedicated key — mirrors the old route.
permission: FREIGHT_PERMS.invoices.view,
subtitle:
"USD invoices are paid by bank transfer. Upload the customer's slip and confirm the payment before the pay window closes.",
"Import and export invoices in USD or ETB that Finance settles by hand (bank transfer or counter). Upload the customer's slip and confirm the payment before the pay window closes.",
Panel: UsdPaymentsPanel,
},
] as const;

View File

@@ -11,6 +11,7 @@ import {
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useMutation, useQuery } from "@tanstack/react-query";
@@ -55,14 +56,19 @@ function formatRemaining(deadlineMs: number, now: number): string | null {
: `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
}
function PayWindowCell({ deadline }: { deadline: string | null }) {
/** Ticks once a second while a deadline is set, so window state updates live. */
function useNow(deadline: string | null): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!deadline) return;
const interval = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(interval);
}, [deadline]);
return now;
}
function PayWindowCell({ deadline }: { deadline: string | null }) {
const now = useNow(deadline);
if (!deadline) {
return (
@@ -88,13 +94,56 @@ function PayWindowCell({ deadline }: { deadline: string | null }) {
);
}
/** True once the pay window has closed — the API refuses confirmation then. */
function windowClosed(row: OfflineUsdInvoice): boolean {
const deadline = row.booking?.paymentDeadline;
return Boolean(deadline && new Date(deadline).getTime() <= Date.now());
/**
* "Confirm paid" for one row. Booking invoices are only confirmable while the
* booking's pay window is open (the API refuses otherwise): no window yet →
* no button; window closed → button disabled with the reason, and it flips
* live the second the countdown hits zero. Non-booking invoices (warehouse,
* clearance…) have no window and stay confirmable.
*/
function ConfirmCell({
row,
onConfirm,
}: {
row: OfflineUsdInvoice;
onConfirm: (row: OfflineUsdInvoice) => void;
}) {
const deadline = row.booking?.paymentDeadline ?? null;
const now = useNow(deadline);
if (row.booking && !deadline) return null;
const closed = Boolean(deadline && new Date(deadline).getTime() <= now);
return (
<Tooltip
label="Pay window closed — the booking can no longer be confirmed as paid."
disabled={!closed}
withArrow
>
<span>
<Button
size="compact-sm"
color="edr-green"
leftSection={<CheckCircle2 size={14} />}
disabled={closed}
onClick={(e) => {
e.stopPropagation();
onConfirm(row);
}}
>
Confirm paid
</Button>
</span>
</Tooltip>
);
}
/** USD Payments tab body of `FinanceHubPage` — page chrome lives in the parent. */
/**
* Manual Payments tab body of `FinanceHubPage` — page chrome lives in the
* parent. Lists open USD and ETB invoices (import and export alike) that
* Finance settles by hand; confirming records the payment the same way an
* online payment would, so the booking advances identically.
*/
export default function UsdPaymentsPanel() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
@@ -103,6 +152,7 @@ export default function UsdPaymentsPanel() {
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>(
"",
);
const [currency, setCurrency] = useState<"" | "USD" | "ETB">("");
const [confirming, setConfirming] = useState<OfflineUsdInvoice | null>(null);
const [slip, setSlip] = useState<File | null>(null);
const [reference, setReference] = useState("");
@@ -119,8 +169,15 @@ export default function UsdPaymentsPanel() {
pageSize: pagination.pageSize,
search: debouncedQuery,
status: statusFilter || undefined,
currency: currency || undefined,
}),
[pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter],
[
pagination.pageIndex,
pagination.pageSize,
debouncedQuery,
statusFilter,
currency,
],
);
const { data, isLoading, isError, refetch, isFetching } = useQuery(
@@ -170,7 +227,9 @@ export default function UsdPaymentsPanel() {
header: "Customer",
cell: ({ row }) => (
<Text size="sm" c="edr-text" truncate maw={200}>
{row.original.company?.name ?? "—"}
{row.original.company?.name ??
row.original.shippingLineCompany?.name ??
"—"}
</Text>
),
},
@@ -179,6 +238,28 @@ export default function UsdPaymentsPanel() {
header: "Booking",
cell: ({ row }) => {
const booking = row.original.booking;
const bookings = row.original.bookings ?? [];
if (!booking && bookings.length) {
// Shipping-line credit invoice: one link per billed booking.
return (
<Group gap={4} wrap="wrap" maw={280}>
{bookings.map((b) => (
<Button
key={b.id}
variant="subtle"
size="compact-xs"
rightSection={<ExternalLink size={11} />}
onClick={(e) => {
e.stopPropagation();
navigate(`/dashboard/booking-requests/${b.id}`);
}}
>
{b.reference}
</Button>
))}
</Group>
);
}
if (!booking) {
return (
<Text size="sm" c="dimmed">
@@ -187,20 +268,41 @@ export default function UsdPaymentsPanel() {
);
}
return (
<Button
variant="subtle"
size="compact-sm"
rightSection={<ExternalLink size={13} />}
onClick={(e) => {
e.stopPropagation();
navigate(`/dashboard/booking-requests/${booking.id}`);
}}
>
{booking.reference}
</Button>
<Group gap={6} wrap="nowrap">
<Button
variant="subtle"
size="compact-sm"
rightSection={<ExternalLink size={13} />}
onClick={(e) => {
e.stopPropagation();
navigate(`/dashboard/booking-requests/${booking.id}`);
}}
>
{booking.reference}
</Button>
{booking.tradeDirection && (
<Badge size="xs" variant="light" radius="sm" color="gray">
{humanize(booking.tradeDirection)}
</Badge>
)}
</Group>
);
},
},
{
id: "currency",
header: "Currency",
cell: ({ row }) => (
<Badge
size="sm"
variant="light"
radius="sm"
color={row.original.currency?.toUpperCase() === "USD" ? "blue" : "teal"}
>
{row.original.currency}
</Badge>
),
},
{
id: "status",
header: "Status",
@@ -239,22 +341,8 @@ export default function UsdPaymentsPanel() {
header: "",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => {
const paid = row.original.status === "PAID";
if (paid || !canConfirm) return null;
return (
<Button
size="compact-sm"
color="edr-green"
leftSection={<CheckCircle2 size={14} />}
disabled={windowClosed(row.original)}
onClick={(e) => {
e.stopPropagation();
setConfirming(row.original);
}}
>
Confirm paid
</Button>
);
if (row.original.status === "PAID" || !canConfirm) return null;
return <ConfirmCell row={row.original} onConfirm={setConfirming} />;
},
},
],
@@ -288,6 +376,20 @@ export default function UsdPaymentsPanel() {
style={{ flex: 1, minWidth: "240px" }}
radius="lg"
/>
<SegmentedControl
size="sm"
radius="md"
value={currency || "all"}
onChange={(v) => {
setCurrency(v === "all" ? "" : (v as "USD" | "ETB"));
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
data={[
{ label: "All", value: "all" },
{ label: "ETB", value: "ETB" },
{ label: "USD", value: "USD" },
]}
/>
<SegmentedControl
size="sm"
radius="md"
@@ -318,7 +420,7 @@ export default function UsdPaymentsPanel() {
</Box>
<Box style={{ overflowX: "auto" }} w="100%">
<Box miw={1040}>
<Box miw={1160}>
<DataTable
columns={columns}
data={rows}
@@ -326,13 +428,13 @@ export default function UsdPaymentsPanel() {
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
emptyMessage={
debouncedQuery
? "No USD invoices match your search."
: "No USD invoices awaiting confirmation."
? "No invoices match your search."
: "No invoices awaiting manual payment confirmation."
}
error={
isError
? {
message: "Failed to load USD invoices.",
message: "Failed to load invoices.",
onRetry: () => void refetch(),
}
: undefined
@@ -361,7 +463,7 @@ export default function UsdPaymentsPanel() {
opened={confirming !== null}
onClose={closeConfirm}
title={
<Text fw={700}>Confirm bank transfer payment</Text>
<Text fw={700}>Confirm manual payment</Text>
}
radius="md"
size="md"
@@ -371,20 +473,21 @@ export default function UsdPaymentsPanel() {
<Text size="sm" c="dimmed">
Confirming settles {confirming.invoiceNumber} in full (
{formatMoney(confirming.balanceAmount, confirming.currency)}) and
marks the booking as paid. Upload the customer&apos;s bank slip
first this cannot be undone.
marks the booking as paid exactly as if the customer had paid
online. Upload the customer&apos;s bank slip or receipt first
this cannot be undone.
</Text>
<PhasedFileDropzone
label="Bank payment slip"
description="PDF or image of the customer's transfer slip."
label="Payment slip / receipt"
description="PDF or image of the customer's bank transfer slip or payment receipt."
value={slip}
onChange={setSlip}
/>
<TextInput
label="Bank reference"
description="Optional — the transfer reference from the slip."
label="Payment reference"
description="Optional — the transfer or receipt reference from the slip."
placeholder="e.g. FT24091234567"
value={reference}
onChange={(e) => setReference(e.target.value)}

View File

@@ -59,7 +59,7 @@ export const invoicesService = {
.then((r) => r.data);
},
/** Finance worklist: USD invoices awaiting bank-transfer confirmation. */
/** Finance worklist: USD and ETB invoices awaiting manual payment confirmation. */
listOfflineUsd(
filter: InvoiceListFilter,
): Promise<PaginatedOfflineUsdInvoices> {
@@ -70,7 +70,7 @@ export const invoicesService = {
.then((r) => r.data);
},
/** Confirm a USD invoice paid by bank transfer — the slip file is required. */
/** Confirm an invoice (USD or ETB) paid manually — the slip file is required. */
confirmOffline(id: string, file: File, reference?: string): Promise<Invoice> {
const body = new FormData();
body.append("file", file);

View File

@@ -13,6 +13,8 @@ export interface InvoiceListFilter {
companyId?: string;
status?: Freight.InvoiceStatus;
search?: string;
/** Manual-payments worklist only. */
currency?: "USD" | "ETB";
}
/** Standard paginated list envelope (matches the customers/bookings service shape). */
@@ -22,17 +24,21 @@ export interface PaginatedInvoices {
}
/**
* A USD invoice on Finance's offline-settlement worklist. Booking-sourced rows
* carry the shipment's pay-window deadline so the list can show the same
* countdown the customer sees — Finance must confirm before it closes.
* A USD or ETB invoice on Finance's manual-settlement worklist. Booking-sourced
* rows carry the shipment's trade direction and pay-window deadline so the list
* can show the same countdown the customer sees — Finance must confirm before
* it closes.
*/
export interface OfflineUsdInvoice extends Invoice {
booking: {
id: string;
reference: string;
tradeDirection: string | null;
paymentDeadline: string | null;
paymentStatus: string;
} | null;
/** Shipping-line credit invoices span many bookings — one entry per credit. */
bookings: { id: string; reference: string; tradeDirection: string | null }[];
}
export interface PaginatedOfflineUsdInvoices {