mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1316 from Tria-plc/freight_feature/usermanagement
Enhance manual payment processing for USD and ETB invoices
This commit is contained in:
@@ -92,7 +92,7 @@ export class BillingController {
|
|||||||
@Get("offline-usd")
|
@Get("offline-usd")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
"Finance worklist: USD invoices settled offline by bank transfer, with booking pay-window context",
|
"Finance worklist: USD and ETB invoices settled manually (bank transfer / counter), with booking pay-window context",
|
||||||
})
|
})
|
||||||
findOfflineUsd(@Query() query: FilterInvoiceDto) {
|
findOfflineUsd(@Query() query: FilterInvoiceDto) {
|
||||||
return this.billingService.findOfflineUsdPaginated(query);
|
return this.billingService.findOfflineUsdPaginated(query);
|
||||||
@@ -104,7 +104,7 @@ export class BillingController {
|
|||||||
@ApiConsumes("multipart/form-data")
|
@ApiConsumes("multipart/form-data")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
"Finance confirms a USD invoice paid by bank transfer — slip file required, settles the full balance",
|
"Finance confirms an invoice (USD or ETB) paid manually — slip file required, settles the full balance",
|
||||||
})
|
})
|
||||||
confirmOffline(
|
confirmOffline(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { Booking } from "../bookings/entities/booking.entity";
|
|||||||
// Entity-only import (no module edge): portal reads resolve shipping-line
|
// Entity-only import (no module edge): portal reads resolve shipping-line
|
||||||
// payers straight off the table.
|
// payers straight off the table.
|
||||||
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
|
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
|
||||||
|
import { ShippingLineCredit } from "../shipping-lines/entities/shipping-line-credit.entity";
|
||||||
import { EimsConfig } from "../../config/eims.config";
|
import { EimsConfig } from "../../config/eims.config";
|
||||||
import { CompaniesService } from "../companies/companies.service";
|
import { CompaniesService } from "../companies/companies.service";
|
||||||
import { EimsInvoiceStatus } from "../eims/eims-registration.types";
|
import { EimsInvoiceStatus } from "../eims/eims-registration.types";
|
||||||
@@ -48,10 +49,18 @@ export interface PayInvoiceOptions {
|
|||||||
export interface OfflineUsdBookingInfo {
|
export interface OfflineUsdBookingInfo {
|
||||||
id: string;
|
id: string;
|
||||||
reference: string;
|
reference: string;
|
||||||
|
tradeDirection: string | null;
|
||||||
paymentDeadline: Date | null;
|
paymentDeadline: Date | null;
|
||||||
paymentStatus: string;
|
paymentStatus: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Row shape of the manual-payments worklist. */
|
||||||
|
export type OfflineUsdInvoiceRow = Invoice & {
|
||||||
|
booking: OfflineUsdBookingInfo | null;
|
||||||
|
/** Shipping-line credit invoices span many bookings — one entry per credit. */
|
||||||
|
bookings: { id: string; reference: string; tradeDirection: string | null }[];
|
||||||
|
};
|
||||||
|
|
||||||
/** A single manual/offline settlement to record against an invoice. */
|
/** A single manual/offline settlement to record against an invoice. */
|
||||||
export interface RecordPaymentInput {
|
export interface RecordPaymentInput {
|
||||||
/** Amount settled by this payment; must be > 0. */
|
/** Amount settled by this payment; must be > 0. */
|
||||||
@@ -340,22 +349,23 @@ export class BillingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finance's offline-settlement worklist: USD invoices (paid by bank transfer,
|
* Finance's manual-settlement worklist: USD invoices (paid by bank transfer,
|
||||||
* never through the gateway), open ones by default or a single status when
|
* never through the gateway) and ETB invoices Finance settles by hand (bank
|
||||||
* filtered. Booking-sourced rows carry the booking's reference and pay-window
|
* transfer / counter) instead of the customer paying online. Open ones by
|
||||||
* deadline so the UI can show the countdown and link to the booking.
|
* default or a single status when filtered; both currencies unless
|
||||||
|
* `currency` narrows it. Booking-sourced rows carry the booking's reference,
|
||||||
|
* trade direction and pay-window deadline so the UI can show the countdown
|
||||||
|
* and link to the booking.
|
||||||
*/
|
*/
|
||||||
async findOfflineUsdPaginated(
|
async findOfflineUsdPaginated(
|
||||||
filter: {
|
filter: {
|
||||||
status?: Freight.InvoiceStatus;
|
status?: Freight.InvoiceStatus;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
currency?: "USD" | "ETB";
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<{
|
): Promise<{ items: OfflineUsdInvoiceRow[]; total: number }> {
|
||||||
items: (Invoice & { booking: OfflineUsdBookingInfo | null })[];
|
|
||||||
total: number;
|
|
||||||
}> {
|
|
||||||
const page = filter.page && filter.page > 0 ? filter.page : 1;
|
const page = filter.page && filter.page > 0 ? filter.page : 1;
|
||||||
const pageSize =
|
const pageSize =
|
||||||
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
|
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
|
||||||
@@ -364,11 +374,16 @@ export class BillingService {
|
|||||||
.getRepository(Invoice)
|
.getRepository(Invoice)
|
||||||
.createQueryBuilder("invoice")
|
.createQueryBuilder("invoice")
|
||||||
.leftJoinAndSelect("invoice.company", "company")
|
.leftJoinAndSelect("invoice.company", "company")
|
||||||
.where("UPPER(invoice.currency) = 'USD'")
|
.where("UPPER(invoice.currency) IN ('USD', 'ETB')")
|
||||||
.orderBy("invoice.issuedAt", "DESC")
|
.orderBy("invoice.issuedAt", "DESC")
|
||||||
.skip((page - 1) * pageSize)
|
.skip((page - 1) * pageSize)
|
||||||
.take(pageSize);
|
.take(pageSize);
|
||||||
|
|
||||||
|
if (filter.currency) {
|
||||||
|
qb.andWhere("UPPER(invoice.currency) = :currency", {
|
||||||
|
currency: filter.currency,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (filter.status) {
|
if (filter.status) {
|
||||||
qb.andWhere("invoice.status = :status", { status: filter.status });
|
qb.andWhere("invoice.status = :status", { status: filter.status });
|
||||||
} else {
|
} else {
|
||||||
@@ -381,7 +396,8 @@ export class BillingService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [items, total] = await qb.getManyAndCount();
|
const [rawItems, total] = await qb.getManyAndCount();
|
||||||
|
const items = await this.attachShippingLineCompanies(rawItems);
|
||||||
|
|
||||||
const bookingIds = items
|
const bookingIds = items
|
||||||
.filter((i) => i.source === "booking")
|
.filter((i) => i.source === "booking")
|
||||||
@@ -389,11 +405,43 @@ export class BillingService {
|
|||||||
const bookings = bookingIds.length
|
const bookings = bookingIds.length
|
||||||
? await this.dataSource.getRepository(Booking).find({
|
? await this.dataSource.getRepository(Booking).find({
|
||||||
where: { id: In(bookingIds) },
|
where: { id: In(bookingIds) },
|
||||||
select: ["id", "reference", "paymentDeadline", "paymentStatus"],
|
select: [
|
||||||
|
"id",
|
||||||
|
"reference",
|
||||||
|
"tradeDirection",
|
||||||
|
"paymentDeadline",
|
||||||
|
"paymentStatus",
|
||||||
|
],
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
const byId = new Map(bookings.map((b) => [b.id, b]));
|
const byId = new Map(bookings.map((b) => [b.id, b]));
|
||||||
|
|
||||||
|
// Shipping-line credit invoices bill many bookings at once; each credit
|
||||||
|
// keeps its own booking link, so collect them per invoice.
|
||||||
|
const creditInvoiceIds = items
|
||||||
|
.filter((i) => i.source === Freight.InvoiceSource.ShippingLineCredit)
|
||||||
|
.map((i) => i.id);
|
||||||
|
const credits = creditInvoiceIds.length
|
||||||
|
? await this.dataSource.getRepository(ShippingLineCredit).find({
|
||||||
|
where: { invoiceId: In(creditInvoiceIds) },
|
||||||
|
relations: { booking: true },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const bookingsByInvoice = new Map<
|
||||||
|
string,
|
||||||
|
OfflineUsdInvoiceRow["bookings"]
|
||||||
|
>();
|
||||||
|
for (const c of credits) {
|
||||||
|
if (!c.invoiceId || !c.booking) continue;
|
||||||
|
const list = bookingsByInvoice.get(c.invoiceId) ?? [];
|
||||||
|
list.push({
|
||||||
|
id: c.booking.id,
|
||||||
|
reference: c.booking.reference,
|
||||||
|
tradeDirection: c.booking.tradeDirection ?? null,
|
||||||
|
});
|
||||||
|
bookingsByInvoice.set(c.invoiceId, list);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: items.map((inv) => {
|
items: items.map((inv) => {
|
||||||
const b = byId.get(inv.sourceId);
|
const b = byId.get(inv.sourceId);
|
||||||
@@ -403,19 +451,22 @@ export class BillingService {
|
|||||||
? {
|
? {
|
||||||
id: b.id,
|
id: b.id,
|
||||||
reference: b.reference,
|
reference: b.reference,
|
||||||
|
tradeDirection: b.tradeDirection ?? null,
|
||||||
paymentDeadline: b.paymentDeadline ?? null,
|
paymentDeadline: b.paymentDeadline ?? null,
|
||||||
paymentStatus: b.paymentStatus,
|
paymentStatus: b.paymentStatus,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
} as Invoice & { booking: OfflineUsdBookingInfo | null };
|
bookings: bookingsByInvoice.get(inv.id) ?? [],
|
||||||
|
} as OfflineUsdInvoiceRow;
|
||||||
}),
|
}),
|
||||||
total,
|
total,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finance confirms a USD invoice as paid by bank transfer: stores the slip
|
* Finance confirms an invoice (USD or ETB) as paid manually — bank transfer
|
||||||
* against the invoice and settles the FULL outstanding balance through
|
* or counter payment: stores the slip against the invoice and settles the
|
||||||
|
* FULL outstanding balance through
|
||||||
* {@link recordPayment}, which flips the invoice to PAID and (for bookings)
|
* {@link recordPayment}, which flips the invoice to PAID and (for bookings)
|
||||||
* emits `booking.invoice.paid` — the same event an online payment fires, so
|
* emits `booking.invoice.paid` — the same event an online payment fires, so
|
||||||
* the booking advances exactly as if it had been paid through the gateway.
|
* the booking advances exactly as if it had been paid through the gateway.
|
||||||
@@ -434,11 +485,6 @@ export class BillingService {
|
|||||||
): Promise<Invoice> {
|
): Promise<Invoice> {
|
||||||
const invoice = await this.invoices.findById(invoiceId);
|
const invoice = await this.invoices.findById(invoiceId);
|
||||||
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||||
if (invoice.currency?.toUpperCase() !== "USD") {
|
|
||||||
throw new BadRequestException(
|
|
||||||
"Offline confirmation is only for USD invoices — this invoice is paid online.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
throw new BadRequestException("The bank payment slip file is required.");
|
throw new BadRequestException("The bank payment slip file is required.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,4 +39,11 @@ export class FilterInvoiceDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(Object.values(Freight.InvoiceStatus))
|
@IsIn(Object.values(Freight.InvoiceStatus))
|
||||||
status?: Freight.InvoiceStatus;
|
status?: Freight.InvoiceStatus;
|
||||||
|
|
||||||
|
/** Manual-payments worklist only: restrict to one currency. */
|
||||||
|
@ApiPropertyOptional({ enum: ["USD", "ETB"] })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }: { value: unknown }) => String(value).toUpperCase())
|
||||||
|
@IsIn(["USD", "ETB"])
|
||||||
|
currency?: "USD" | "ETB";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2414,6 +2414,8 @@ export const ROLE_PERMISSION_PRESETS = {
|
|||||||
FREIGHT_PERMS.bookings.view,
|
FREIGHT_PERMS.bookings.view,
|
||||||
FREIGHT_PERMS.invoices.view,
|
FREIGHT_PERMS.invoices.view,
|
||||||
FREIGHT_PERMS.invoices.export,
|
FREIGHT_PERMS.invoices.export,
|
||||||
|
// Manual settlement (bank transfer / counter) of USD and ETB invoices.
|
||||||
|
FREIGHT_PERMS.invoices.confirmOffline,
|
||||||
// Deliberately NOT granted here: invoices:eims_register, eims_resolve, eims_cancel,
|
// Deliberately NOT granted here: invoices:eims_register, eims_resolve, eims_cancel,
|
||||||
// eims_receipt_register, eims:memo_issue. Automatic filing needs no human permission at all
|
// eims_receipt_register, eims:memo_issue. Automatic filing needs no human permission at all
|
||||||
// (the cron sweep runs as the system); these are the *manual* exceptional-operations
|
// (the cron sweep runs as the system); these are the *manual* exceptional-operations
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ const App = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{/* Merged Invoices / Payments / USD Payments hub — tabs switch via
|
{/* 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
|
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
|
tab hides itself if the user lacks the permission it used to be
|
||||||
routed on. */}
|
routed on. */}
|
||||||
@@ -352,7 +352,7 @@ const App = () => {
|
|||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="usd-payments"
|
path="usd-payments"
|
||||||
element={<Navigate to="/dashboard/invoices?tab=usd-payments" replace />}
|
element={<Navigate to="/dashboard/invoices?tab=manual-payments" replace />}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="invoices/:id"
|
path="invoices/:id"
|
||||||
|
|||||||
@@ -29,13 +29,13 @@ const TABS = [
|
|||||||
Panel: InvoicesPanel,
|
Panel: InvoicesPanel,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "usd-payments",
|
key: "manual-payments",
|
||||||
label: "USD Payments",
|
label: "Manual Payments",
|
||||||
icon: Landmark,
|
icon: Landmark,
|
||||||
// Same gate as Invoices, not a dedicated key — mirrors the old route.
|
// Same gate as Invoices, not a dedicated key — mirrors the old route.
|
||||||
permission: FREIGHT_PERMS.invoices.view,
|
permission: FREIGHT_PERMS.invoices.view,
|
||||||
subtitle:
|
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,
|
Panel: UsdPaymentsPanel,
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
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)}`;
|
: `${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());
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!deadline) return;
|
if (!deadline) return;
|
||||||
const interval = setInterval(() => setNow(Date.now()), 1000);
|
const interval = setInterval(() => setNow(Date.now()), 1000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [deadline]);
|
}, [deadline]);
|
||||||
|
return now;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PayWindowCell({ deadline }: { deadline: string | null }) {
|
||||||
|
const now = useNow(deadline);
|
||||||
|
|
||||||
if (!deadline) {
|
if (!deadline) {
|
||||||
return (
|
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 {
|
* "Confirm paid" for one row. Booking invoices are only confirmable while the
|
||||||
const deadline = row.booking?.paymentDeadline;
|
* booking's pay window is open (the API refuses otherwise): no window yet →
|
||||||
return Boolean(deadline && new Date(deadline).getTime() <= Date.now());
|
* 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() {
|
export default function UsdPaymentsPanel() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
@@ -103,6 +152,7 @@ export default function UsdPaymentsPanel() {
|
|||||||
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>(
|
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>(
|
||||||
"",
|
"",
|
||||||
);
|
);
|
||||||
|
const [currency, setCurrency] = useState<"" | "USD" | "ETB">("");
|
||||||
const [confirming, setConfirming] = useState<OfflineUsdInvoice | null>(null);
|
const [confirming, setConfirming] = useState<OfflineUsdInvoice | null>(null);
|
||||||
const [slip, setSlip] = useState<File | null>(null);
|
const [slip, setSlip] = useState<File | null>(null);
|
||||||
const [reference, setReference] = useState("");
|
const [reference, setReference] = useState("");
|
||||||
@@ -119,8 +169,15 @@ export default function UsdPaymentsPanel() {
|
|||||||
pageSize: pagination.pageSize,
|
pageSize: pagination.pageSize,
|
||||||
search: debouncedQuery,
|
search: debouncedQuery,
|
||||||
status: statusFilter || undefined,
|
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(
|
const { data, isLoading, isError, refetch, isFetching } = useQuery(
|
||||||
@@ -170,7 +227,9 @@ export default function UsdPaymentsPanel() {
|
|||||||
header: "Customer",
|
header: "Customer",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Text size="sm" c="edr-text" truncate maw={200}>
|
<Text size="sm" c="edr-text" truncate maw={200}>
|
||||||
{row.original.company?.name ?? "—"}
|
{row.original.company?.name ??
|
||||||
|
row.original.shippingLineCompany?.name ??
|
||||||
|
"—"}
|
||||||
</Text>
|
</Text>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -179,6 +238,28 @@ export default function UsdPaymentsPanel() {
|
|||||||
header: "Booking",
|
header: "Booking",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const booking = row.original.booking;
|
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) {
|
if (!booking) {
|
||||||
return (
|
return (
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
@@ -187,20 +268,41 @@ export default function UsdPaymentsPanel() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Button
|
<Group gap={6} wrap="nowrap">
|
||||||
variant="subtle"
|
<Button
|
||||||
size="compact-sm"
|
variant="subtle"
|
||||||
rightSection={<ExternalLink size={13} />}
|
size="compact-sm"
|
||||||
onClick={(e) => {
|
rightSection={<ExternalLink size={13} />}
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
navigate(`/dashboard/booking-requests/${booking.id}`);
|
e.stopPropagation();
|
||||||
}}
|
navigate(`/dashboard/booking-requests/${booking.id}`);
|
||||||
>
|
}}
|
||||||
{booking.reference}
|
>
|
||||||
</Button>
|
{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",
|
id: "status",
|
||||||
header: "Status",
|
header: "Status",
|
||||||
@@ -239,22 +341,8 @@ export default function UsdPaymentsPanel() {
|
|||||||
header: "",
|
header: "",
|
||||||
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const paid = row.original.status === "PAID";
|
if (row.original.status === "PAID" || !canConfirm) return null;
|
||||||
if (paid || !canConfirm) return null;
|
return <ConfirmCell row={row.original} onConfirm={setConfirming} />;
|
||||||
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>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -288,6 +376,20 @@ export default function UsdPaymentsPanel() {
|
|||||||
style={{ flex: 1, minWidth: "240px" }}
|
style={{ flex: 1, minWidth: "240px" }}
|
||||||
radius="lg"
|
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
|
<SegmentedControl
|
||||||
size="sm"
|
size="sm"
|
||||||
radius="md"
|
radius="md"
|
||||||
@@ -318,7 +420,7 @@ export default function UsdPaymentsPanel() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box style={{ overflowX: "auto" }} w="100%">
|
<Box style={{ overflowX: "auto" }} w="100%">
|
||||||
<Box miw={1040}>
|
<Box miw={1160}>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={rows}
|
data={rows}
|
||||||
@@ -326,13 +428,13 @@ export default function UsdPaymentsPanel() {
|
|||||||
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
|
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
|
||||||
emptyMessage={
|
emptyMessage={
|
||||||
debouncedQuery
|
debouncedQuery
|
||||||
? "No USD invoices match your search."
|
? "No invoices match your search."
|
||||||
: "No USD invoices awaiting confirmation."
|
: "No invoices awaiting manual payment confirmation."
|
||||||
}
|
}
|
||||||
error={
|
error={
|
||||||
isError
|
isError
|
||||||
? {
|
? {
|
||||||
message: "Failed to load USD invoices.",
|
message: "Failed to load invoices.",
|
||||||
onRetry: () => void refetch(),
|
onRetry: () => void refetch(),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
@@ -361,7 +463,7 @@ export default function UsdPaymentsPanel() {
|
|||||||
opened={confirming !== null}
|
opened={confirming !== null}
|
||||||
onClose={closeConfirm}
|
onClose={closeConfirm}
|
||||||
title={
|
title={
|
||||||
<Text fw={700}>Confirm bank transfer payment</Text>
|
<Text fw={700}>Confirm manual payment</Text>
|
||||||
}
|
}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
@@ -371,20 +473,21 @@ export default function UsdPaymentsPanel() {
|
|||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Confirming settles {confirming.invoiceNumber} in full (
|
Confirming settles {confirming.invoiceNumber} in full (
|
||||||
{formatMoney(confirming.balanceAmount, confirming.currency)}) and
|
{formatMoney(confirming.balanceAmount, confirming.currency)}) and
|
||||||
marks the booking as paid. Upload the customer's bank slip
|
marks the booking as paid — exactly as if the customer had paid
|
||||||
first — this cannot be undone.
|
online. Upload the customer's bank slip or receipt first —
|
||||||
|
this cannot be undone.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<PhasedFileDropzone
|
<PhasedFileDropzone
|
||||||
label="Bank payment slip"
|
label="Payment slip / receipt"
|
||||||
description="PDF or image of the customer's transfer slip."
|
description="PDF or image of the customer's bank transfer slip or payment receipt."
|
||||||
value={slip}
|
value={slip}
|
||||||
onChange={setSlip}
|
onChange={setSlip}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Bank reference"
|
label="Payment reference"
|
||||||
description="Optional — the transfer reference from the slip."
|
description="Optional — the transfer or receipt reference from the slip."
|
||||||
placeholder="e.g. FT24091234567"
|
placeholder="e.g. FT24091234567"
|
||||||
value={reference}
|
value={reference}
|
||||||
onChange={(e) => setReference(e.target.value)}
|
onChange={(e) => setReference(e.target.value)}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export const invoicesService = {
|
|||||||
.then((r) => r.data);
|
.then((r) => r.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Finance worklist: USD invoices awaiting bank-transfer confirmation. */
|
/** Finance worklist: USD and ETB invoices awaiting manual payment confirmation. */
|
||||||
listOfflineUsd(
|
listOfflineUsd(
|
||||||
filter: InvoiceListFilter,
|
filter: InvoiceListFilter,
|
||||||
): Promise<PaginatedOfflineUsdInvoices> {
|
): Promise<PaginatedOfflineUsdInvoices> {
|
||||||
@@ -70,7 +70,7 @@ export const invoicesService = {
|
|||||||
.then((r) => r.data);
|
.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> {
|
confirmOffline(id: string, file: File, reference?: string): Promise<Invoice> {
|
||||||
const body = new FormData();
|
const body = new FormData();
|
||||||
body.append("file", file);
|
body.append("file", file);
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export interface InvoiceListFilter {
|
|||||||
companyId?: string;
|
companyId?: string;
|
||||||
status?: Freight.InvoiceStatus;
|
status?: Freight.InvoiceStatus;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
/** Manual-payments worklist only. */
|
||||||
|
currency?: "USD" | "ETB";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Standard paginated list envelope (matches the customers/bookings service shape). */
|
/** 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
|
* A USD or ETB invoice on Finance's manual-settlement worklist. Booking-sourced
|
||||||
* carry the shipment's pay-window deadline so the list can show the same
|
* rows carry the shipment's trade direction and pay-window deadline so the list
|
||||||
* countdown the customer sees — Finance must confirm before it closes.
|
* can show the same countdown the customer sees — Finance must confirm before
|
||||||
|
* it closes.
|
||||||
*/
|
*/
|
||||||
export interface OfflineUsdInvoice extends Invoice {
|
export interface OfflineUsdInvoice extends Invoice {
|
||||||
booking: {
|
booking: {
|
||||||
id: string;
|
id: string;
|
||||||
reference: string;
|
reference: string;
|
||||||
|
tradeDirection: string | null;
|
||||||
paymentDeadline: string | null;
|
paymentDeadline: string | null;
|
||||||
paymentStatus: string;
|
paymentStatus: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
/** Shipping-line credit invoices span many bookings — one entry per credit. */
|
||||||
|
bookings: { id: string; reference: string; tradeDirection: string | null }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginatedOfflineUsdInvoices {
|
export interface PaginatedOfflineUsdInvoices {
|
||||||
|
|||||||
Reference in New Issue
Block a user