mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: setup the invoice page in the portal
This commit is contained in:
@@ -31,7 +31,8 @@ import LoginPage from "./pages/accounts/LoginPage";
|
|||||||
import SetPasswordPage from "./pages/accounts/SetPasswordPage";
|
import SetPasswordPage from "./pages/accounts/SetPasswordPage";
|
||||||
import SignupPage from "./pages/accounts/SignupPage";
|
import SignupPage from "./pages/accounts/SignupPage";
|
||||||
import VerificationOtpPage from "./pages/accounts/VerificationOtpPage";
|
import VerificationOtpPage from "./pages/accounts/VerificationOtpPage";
|
||||||
import BillingPage from "./pages/billing/BillingPage";
|
import InvoiceDetailPage from "./pages/billing/InvoiceDetailPage";
|
||||||
|
import InvoicesList from "./pages/billing/InvoicesList";
|
||||||
import BookingContractPage from "./pages/bookings/BookingContractPage";
|
import BookingContractPage from "./pages/bookings/BookingContractPage";
|
||||||
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
|
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
|
||||||
import EditBookingPage from "./pages/bookings/EditBookingPage";
|
import EditBookingPage from "./pages/bookings/EditBookingPage";
|
||||||
@@ -188,7 +189,7 @@ const sidebarItems: SidebarItem[] = [
|
|||||||
icon: <MapPin size={18} />,
|
icon: <MapPin size={18} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Billing",
|
label: "Invoices",
|
||||||
href: "/billing",
|
href: "/billing",
|
||||||
icon: <Receipt size={18} />,
|
icon: <Receipt size={18} />,
|
||||||
},
|
},
|
||||||
@@ -285,7 +286,8 @@ const App = () => {
|
|||||||
/>
|
/>
|
||||||
<Route path="/contracts/:id" element={<ContractDetailPage />} />
|
<Route path="/contracts/:id" element={<ContractDetailPage />} />
|
||||||
<Route path="/tracking" element={<TrackingPage />} />
|
<Route path="/tracking" element={<TrackingPage />} />
|
||||||
<Route path="/billing" element={<BillingPage />} />
|
<Route path="/billing" element={<InvoicesList />} />
|
||||||
|
<Route path="/billing/:id" element={<InvoiceDetailPage />} />
|
||||||
{/* Profile was merged into Settings — keep old links working. */}
|
{/* Profile was merged into Settings — keep old links working. */}
|
||||||
<Route
|
<Route
|
||||||
path="/profile"
|
path="/profile"
|
||||||
|
|||||||
@@ -143,4 +143,10 @@ export const URL_CONSTANTS = {
|
|||||||
INTENT: (bookingId: string) => `/api/payments/intents/${bookingId}`,
|
INTENT: (bookingId: string) => `/api/payments/intents/${bookingId}`,
|
||||||
CHECKOUT: "/api/payments/checkout",
|
CHECKOUT: "/api/payments/checkout",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
BILLING: {
|
||||||
|
MY_INVOICES: "/api/billing/my-invoices",
|
||||||
|
MY_INVOICE_BY_ID: (id: string) => `/api/billing/my-invoices/${id}`,
|
||||||
|
PAY_INVOICE: (id: string) => `/api/billing/my-invoices/${id}/pay`,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
||||||
// export const API_BASE_URL = 'http://localhost:3001';
|
export const API_BASE_URL = 'http://localhost:3001';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* URL that streams an uploaded file through the API by its UUID. Routes the
|
* URL that streams an uploaded file through the API by its UUID. Routes the
|
||||||
|
|||||||
23
apps/edr-freight-web/portal/src/lib/currency.ts
Normal file
23
apps/edr-freight-web/portal/src/lib/currency.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/** Currency code carried on invoices / dashboard figures (ETB, USD, DJF, …). */
|
||||||
|
export type Currency = string;
|
||||||
|
|
||||||
|
const SYMBOLS: Record<string, string> = {
|
||||||
|
USD: "$",
|
||||||
|
ETB: "Br",
|
||||||
|
DJF: "DJF",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a money amount with its currency symbol, e.g. `Br 12,500.00`.
|
||||||
|
* Unknown currency codes fall back to printing the raw code.
|
||||||
|
*/
|
||||||
|
export function formatCurrency(
|
||||||
|
amount: number,
|
||||||
|
currency: Currency = "ETB",
|
||||||
|
): string {
|
||||||
|
const symbol = SYMBOLS[currency] ?? currency;
|
||||||
|
return `${symbol} ${Number(amount ?? 0).toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { invoices, type Invoice } from "@/pages/billing/invoices.mock";
|
|
||||||
import { customers, type Customer } from "@/pages/customers/customers.mock";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mock "logged-in customer". When auth integrates, replace this with the value
|
|
||||||
* pulled from `@edr/iamui-common` / the JWT context.
|
|
||||||
*/
|
|
||||||
const CURRENT_CUSTOMER_ID = 1;
|
|
||||||
|
|
||||||
export function getCurrentCustomer(): Customer {
|
|
||||||
return (
|
|
||||||
customers.find((c) => c.id === CURRENT_CUSTOMER_ID) ??
|
|
||||||
(customers[0] as Customer)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMyInvoices(): Invoice[] {
|
|
||||||
const me = getCurrentCustomer();
|
|
||||||
return invoices.filter((inv) => inv.customerId === me.id);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Currency } from "@/pages/billing/invoices.mock";
|
import type { Currency } from "@/lib/currency";
|
||||||
import { formatCurrency } from "@/pages/billing/invoices.mock";
|
import { formatCurrency } from "@/lib/currency";
|
||||||
import { Group, Grid, Select, Stack } from "@mantine/core";
|
import { Group, Grid, Select, Stack } from "@mantine/core";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Box, Group, Skeleton, Text } from "@mantine/core";
|
import { Box, Group, Skeleton, Text } from "@mantine/core";
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import type { Currency } from "@/pages/billing/invoices.mock";
|
import type { Currency } from "@/lib/currency";
|
||||||
import { formatCurrency } from "@/pages/billing/invoices.mock";
|
import { formatCurrency } from "@/lib/currency";
|
||||||
import { formatPct } from "../constants";
|
import { formatPct } from "../constants";
|
||||||
import { Card } from "./Card";
|
import { Card } from "./Card";
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Currency, InvoiceStatus } from "@/pages/billing/invoices.mock";
|
import { formatCurrency } from "@/lib/currency";
|
||||||
import { formatCurrency } from "@/pages/billing/invoices.mock";
|
import type { PortalInvoice } from "@/services/invoices.service";
|
||||||
import { Box, Group, Stack, Text } from "@mantine/core";
|
import { Box, Group, Stack, Text } from "@mantine/core";
|
||||||
import { format } from "date-fns";
|
import { Freight } from "@edr/types";
|
||||||
import { CheckCircle2, ChevronRight, Clock3, Zap } from "lucide-react";
|
import { CheckCircle2, ChevronRight, Clock3, Zap } from "lucide-react";
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
@@ -10,26 +10,22 @@ import { Card } from "./Card";
|
|||||||
import { EmptyState } from "./EmptyState";
|
import { EmptyState } from "./EmptyState";
|
||||||
|
|
||||||
interface InvoicesSectionProps {
|
interface InvoicesSectionProps {
|
||||||
invoices: Array<{
|
invoices: PortalInvoice[];
|
||||||
id: number;
|
|
||||||
number: string;
|
|
||||||
bookingReference: string;
|
|
||||||
amount: number;
|
|
||||||
currency: Currency;
|
|
||||||
status: InvoiceStatus;
|
|
||||||
dueDate: string;
|
|
||||||
paidDate: string | null;
|
|
||||||
}>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const titleCase = (v: string) =>
|
||||||
|
v ? v.charAt(0).toUpperCase() + v.slice(1).toLowerCase() : "";
|
||||||
|
|
||||||
export const InvoicesSection = memo(function InvoicesSection({
|
export const InvoicesSection = memo(function InvoicesSection({
|
||||||
invoices,
|
invoices,
|
||||||
}: InvoicesSectionProps) {
|
}: InvoicesSectionProps) {
|
||||||
const outstandingInvoices = invoices.filter(
|
const outstandingInvoices = invoices.filter(
|
||||||
(inv) => inv.status === "Sent" || inv.status === "Overdue",
|
(inv) =>
|
||||||
|
inv.status === Freight.InvoiceStatus.Pending ||
|
||||||
|
inv.status === Freight.InvoiceStatus.Overdue,
|
||||||
);
|
);
|
||||||
const totalOutstanding = outstandingInvoices.reduce(
|
const totalOutstanding = outstandingInvoices.reduce(
|
||||||
(sum, inv) => sum + inv.amount,
|
(sum, inv) => sum + Number(inv.totalAmount),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -56,14 +52,9 @@ export const InvoicesSection = memo(function InvoicesSection({
|
|||||||
<Text fz={24} fw={800} mt={4} c="edr-text">
|
<Text fz={24} fw={800} mt={4} c="edr-text">
|
||||||
{formatCurrency(totalOutstanding || 0, "ETB")}
|
{formatCurrency(totalOutstanding || 0, "ETB")}
|
||||||
</Text>
|
</Text>
|
||||||
<Group
|
<Group justify="space-between" align="center" mt={8} wrap="nowrap">
|
||||||
justify="space-between"
|
|
||||||
align="center"
|
|
||||||
mt={8}
|
|
||||||
wrap="nowrap"
|
|
||||||
>
|
|
||||||
<Text fz={12} c="edr-amber-text">
|
<Text fz={12} c="edr-amber-text">
|
||||||
{outstandingInvoices.length || 2} invoices unpaid
|
{outstandingInvoices.length} invoices unpaid
|
||||||
</Text>
|
</Text>
|
||||||
<Group
|
<Group
|
||||||
gap={5}
|
gap={5}
|
||||||
@@ -87,61 +78,55 @@ export const InvoicesSection = memo(function InvoicesSection({
|
|||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
{invoices.map((invoice, i) => {
|
{invoices.map((invoice, i) => {
|
||||||
const badge = INVOICE_BADGE[invoice.status];
|
const badge = INVOICE_BADGE[invoice.status];
|
||||||
const dueText =
|
const isPaid = invoice.status === Freight.InvoiceStatus.Paid;
|
||||||
invoice.status === "Paid"
|
const isOverdue = invoice.status === Freight.InvoiceStatus.Overdue;
|
||||||
? `Paid ${format(new Date(invoice.paidDate ?? invoice.dueDate), "MMM d")}`
|
const dueText = isPaid
|
||||||
: invoice.status === "Overdue"
|
? "Paid"
|
||||||
? "Overdue 3 days"
|
: isOverdue
|
||||||
: `Due ${invoice.dueDate}`;
|
? "Overdue"
|
||||||
const DueIcon =
|
: `Due ${new Date(invoice.dueAt).toLocaleDateString()}`;
|
||||||
invoice.status === "Paid" ? CheckCircle2 : Clock3;
|
const DueIcon = isPaid ? CheckCircle2 : Clock3;
|
||||||
const dueIconColor =
|
const dueIconColor = isPaid ? cv("edr-green.5") : cv("edr-muted");
|
||||||
invoice.status === "Paid"
|
|
||||||
? cv("edr-green.5")
|
|
||||||
: cv("edr-muted");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box key={invoice.id}>
|
<Box key={invoice.id}>
|
||||||
{i > 0 && <Box h={1} bg="edr-divider" />}
|
{i > 0 && <Box h={1} bg="edr-divider" />}
|
||||||
<Stack gap={8} py={10}>
|
<Stack gap={8} py={10}>
|
||||||
<Group
|
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||||
justify="space-between"
|
|
||||||
align="flex-start"
|
|
||||||
wrap="nowrap"
|
|
||||||
>
|
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz={13} fw={700} c="edr-text">
|
<Text fz={13} fw={700} c="edr-text">
|
||||||
{invoice.number}
|
{invoice.invoiceNumber}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fz={11} c="edr-muted">
|
<Text fz={11} c="edr-muted">
|
||||||
{invoice.bookingReference}
|
{titleCase(invoice.source)} · {titleCase(invoice.type)}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Text fz={14} fw={700} c="edr-text">
|
<Text fz={14} fw={700} c="edr-text">
|
||||||
{formatCurrency(invoice.amount, invoice.currency)}
|
{formatCurrency(
|
||||||
|
Number(invoice.totalAmount),
|
||||||
|
invoice.currency,
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Group
|
<Group justify="space-between" align="center" wrap="nowrap">
|
||||||
justify="space-between"
|
|
||||||
align="center"
|
|
||||||
wrap="nowrap"
|
|
||||||
>
|
|
||||||
<Group gap={5} align="center">
|
<Group gap={5} align="center">
|
||||||
<DueIcon size={13} color={dueIconColor} />
|
<DueIcon size={13} color={dueIconColor} />
|
||||||
<Text fz={12} c="edr-muted">
|
<Text fz={12} c="edr-muted">
|
||||||
{dueText}
|
{dueText}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Box
|
{badge && (
|
||||||
bg={badge.bg}
|
<Box
|
||||||
px={10}
|
bg={badge.bg}
|
||||||
py={4}
|
px={10}
|
||||||
className="rounded-full"
|
py={4}
|
||||||
>
|
className="rounded-full"
|
||||||
<Text fz={11} fw={700} c={badge.text}>
|
>
|
||||||
{badge.label}
|
<Text fz={11} fw={700} c={badge.text}>
|
||||||
</Text>
|
{badge.label}
|
||||||
</Box>
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { formatCurrency } from "@/pages/billing/invoices.mock";
|
import { formatCurrency } from "@/lib/currency";
|
||||||
import { SimpleGrid } from "@mantine/core";
|
import { SimpleGrid } from "@mantine/core";
|
||||||
import { CheckCircle2, Clock3, Layers, Truck, Wallet } from "lucide-react";
|
import { CheckCircle2, Clock3, Layers, Truck, Wallet } from "lucide-react";
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
Wallet,
|
Wallet,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { InvoiceStatus } from "@/pages/billing/invoices.mock";
|
import { Freight } from "@edr/types";
|
||||||
|
|
||||||
export const cv = (token: string) => {
|
export const cv = (token: string) => {
|
||||||
const [name, shade] = token.split(".");
|
const [name, shade] = token.split(".");
|
||||||
@@ -514,12 +514,13 @@ export const ACTION_PROPS: Record<
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const INVOICE_BADGE: Record<
|
export const INVOICE_BADGE: Record<
|
||||||
InvoiceStatus,
|
Freight.InvoiceStatus,
|
||||||
{ label: string; bg: string; text: string }
|
{ label: string; bg: string; text: string }
|
||||||
> = {
|
> = {
|
||||||
Draft: { label: "Draft", bg: "edr-slate-soft", text: "edr-slate" },
|
[Freight.InvoiceStatus.Draft]: { label: "Draft", bg: "edr-slate-soft", text: "edr-slate" },
|
||||||
Sent: { label: "Due soon", bg: "edr-amber-soft", text: "edr-amber-text" },
|
[Freight.InvoiceStatus.Pending]: { label: "Due soon", bg: "edr-amber-soft", text: "edr-amber-text" },
|
||||||
Paid: { label: "Paid", bg: "edr-soft", text: "edr-green.7" },
|
[Freight.InvoiceStatus.Paid]: { label: "Paid", bg: "edr-soft", text: "edr-green.7" },
|
||||||
Overdue: { label: "Overdue", bg: "edr-red-soft", text: "edr-red" },
|
[Freight.InvoiceStatus.Overdue]: { label: "Overdue", bg: "edr-red-soft", text: "edr-red" },
|
||||||
Cancelled: { label: "Cancelled", bg: "edr-slate-soft", text: "edr-slate" },
|
[Freight.InvoiceStatus.Cancelled]: { label: "Cancelled", bg: "edr-slate-soft", text: "edr-slate" },
|
||||||
|
[Freight.InvoiceStatus.Refunded]: { label: "Refunded", bg: "edr-blue-soft", text: "edr-blue" },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useMemo } from "react";
|
import { Freight } from "@edr/types";
|
||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
import { getMyInvoices } from "@/lib/currentCustomer";
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { ACTIVE_STATUSES } from "./constants";
|
import { ACTIVE_STATUSES } from "./constants";
|
||||||
|
|
||||||
export function useMyPortalData(selectedProfileId?: string) {
|
export function useMyPortalData(selectedProfileId?: string) {
|
||||||
const { user, customer, company } = useAuth();
|
const { user, customer, company } = useAuth();
|
||||||
const myInvoices = useMemo(() => getMyInvoices(), []);
|
|
||||||
|
const invoicesQuery = useQuery(api.invoices.listMy.queryOptions());
|
||||||
|
const myInvoices = invoicesQuery.data ?? [];
|
||||||
|
|
||||||
const companyProfiles = company?.company?.companyProfiles ?? [];
|
const companyProfiles = company?.company?.companyProfiles ?? [];
|
||||||
|
|
||||||
@@ -59,11 +60,13 @@ export function useMyPortalData(selectedProfileId?: string) {
|
|||||||
).length;
|
).length;
|
||||||
|
|
||||||
const outstandingInvoices = myInvoices.filter(
|
const outstandingInvoices = myInvoices.filter(
|
||||||
(inv) => inv.status === "Sent" || inv.status === "Overdue",
|
(inv) =>
|
||||||
|
inv.status === Freight.InvoiceStatus.Pending ||
|
||||||
|
inv.status === Freight.InvoiceStatus.Overdue,
|
||||||
);
|
);
|
||||||
|
|
||||||
const totalOutstanding = outstandingInvoices.reduce(
|
const totalOutstanding = outstandingInvoices.reduce(
|
||||||
(sum, inv) => sum + inv.amount,
|
(sum, inv) => sum + Number(inv.totalAmount),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -91,6 +94,7 @@ export function useMyPortalData(selectedProfileId?: string) {
|
|||||||
bookingsQuery,
|
bookingsQuery,
|
||||||
dashboardQuery,
|
dashboardQuery,
|
||||||
contractsQuery,
|
contractsQuery,
|
||||||
|
invoicesQuery,
|
||||||
allContracts,
|
allContracts,
|
||||||
recentContracts,
|
recentContracts,
|
||||||
activeContractsCount,
|
activeContractsCount,
|
||||||
|
|||||||
@@ -1,382 +0,0 @@
|
|||||||
import { useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
AlertCircle,
|
|
||||||
Clock,
|
|
||||||
DollarSign,
|
|
||||||
Download,
|
|
||||||
Filter,
|
|
||||||
MoreHorizontal,
|
|
||||||
Pencil,
|
|
||||||
Plus,
|
|
||||||
Receipt,
|
|
||||||
Search,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
|
||||||
import NewInvoicePage from "./NewInvoicePage";
|
|
||||||
import DeleteInvoiceDialog from "./DeleteInvoiceDialog";
|
|
||||||
import { formatCurrency, invoices, type InvoiceStatus } from "./invoices.mock";
|
|
||||||
import {
|
|
||||||
DataTable,
|
|
||||||
DataTableFooter,
|
|
||||||
type ColumnDef,
|
|
||||||
usePagination,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
CardDescription,
|
|
||||||
CardContent,
|
|
||||||
Input,
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
} from "@edr/ui-common";
|
|
||||||
|
|
||||||
type FilterValue = "All" | InvoiceStatus;
|
|
||||||
|
|
||||||
const FILTERS: FilterValue[] = [
|
|
||||||
"All",
|
|
||||||
"Draft",
|
|
||||||
"Sent",
|
|
||||||
"Paid",
|
|
||||||
"Overdue",
|
|
||||||
"Cancelled",
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function BillingPage() {
|
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
|
||||||
const [filter, setFilter] = useState<FilterValue>("All");
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
const q = query.trim().toLowerCase();
|
|
||||||
return invoices.filter((inv) => {
|
|
||||||
if (filter !== "All" && inv.status !== filter) return false;
|
|
||||||
if (!q) return true;
|
|
||||||
return (
|
|
||||||
inv.number.toLowerCase().includes(q) ||
|
|
||||||
inv.customer.toLowerCase().includes(q) ||
|
|
||||||
inv.bookingReference.toLowerCase().includes(q)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}, [filter, query]);
|
|
||||||
|
|
||||||
const total = filtered.length;
|
|
||||||
const pageCount = Math.ceil(total / pagination.pageSize);
|
|
||||||
const start = pagination.pageIndex * pagination.pageSize;
|
|
||||||
const end = Math.min(start + pagination.pageSize, total);
|
|
||||||
|
|
||||||
const paginatedData = useMemo(
|
|
||||||
() => filtered.slice(start, end),
|
|
||||||
[start, end, filtered],
|
|
||||||
);
|
|
||||||
|
|
||||||
const totalRevenue = invoices
|
|
||||||
.filter((inv) => inv.status === "Paid" && inv.currency === "USD")
|
|
||||||
.reduce((sum, inv) => sum + inv.amount, 0);
|
|
||||||
const outstanding = invoices
|
|
||||||
.filter(
|
|
||||||
(inv) =>
|
|
||||||
(inv.status === "Sent" || inv.status === "Overdue") &&
|
|
||||||
inv.currency === "USD",
|
|
||||||
)
|
|
||||||
.reduce((sum, inv) => sum + inv.amount, 0);
|
|
||||||
const overdueCount = invoices.filter(
|
|
||||||
(inv) => inv.status === "Overdue",
|
|
||||||
).length;
|
|
||||||
|
|
||||||
const columns: ColumnDef<(typeof invoices)[number]>[] = [
|
|
||||||
{
|
|
||||||
id: "invoice",
|
|
||||||
header: "Invoice",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const inv = row.original;
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
|
||||||
<Receipt />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-slate-900">{inv.number}</p>
|
|
||||||
<p className="text-sm text-slate-500">Issued {inv.issueDate}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "customer",
|
|
||||||
header: "Customer",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "bookingReference",
|
|
||||||
header: "Booking",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "amount",
|
|
||||||
header: "Amount",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const inv = row.original;
|
|
||||||
return (
|
|
||||||
<span className="text-sm font-medium text-slate-900">
|
|
||||||
{formatCurrency(inv.amount, inv.currency)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "dueDate",
|
|
||||||
header: "Due Date",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "status",
|
|
||||||
header: "Status",
|
|
||||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
size: 40,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const invoice = row.original;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex justify-end"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="outline" size="icon">
|
|
||||||
<MoreHorizontal />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<Download />
|
|
||||||
Download
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<NewInvoicePage
|
|
||||||
mode="edit"
|
|
||||||
invoice={{
|
|
||||||
number: invoice.number,
|
|
||||||
customerId: invoice.customerId,
|
|
||||||
bookingReference: invoice.bookingReference,
|
|
||||||
amount: invoice.amount,
|
|
||||||
currency: invoice.currency,
|
|
||||||
status: invoice.status,
|
|
||||||
issueDate: invoice.issueDate,
|
|
||||||
dueDate: invoice.dueDate,
|
|
||||||
notes: invoice.notes,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenuItem onSelect={(e: Event) => e.preventDefault()}>
|
|
||||||
<Pencil />
|
|
||||||
Edit
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</NewInvoicePage>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DeleteInvoiceDialog invoiceNumber={invoice.number}>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={(e: Event) => e.preventDefault()}
|
|
||||||
variant="destructive"
|
|
||||||
>
|
|
||||||
<Trash2 />
|
|
||||||
Void
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DeleteInvoiceDialog>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen p-6">
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Breadcrumbs items={[{ label: "Billing" }]} />
|
|
||||||
|
|
||||||
<Card className="p-6 flex-row justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
|
|
||||||
Billing
|
|
||||||
</h1>
|
|
||||||
<p className="mt-1 text-sm text-secondary-foreground">
|
|
||||||
Manage invoices, payments, and financial records.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
|
|
||||||
<div className="relative w-full sm:w-80">
|
|
||||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
type="search"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => {
|
|
||||||
setQuery(e.target.value);
|
|
||||||
setPagination({
|
|
||||||
pageIndex: 0,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
placeholder="Search invoices..."
|
|
||||||
className="pl-8!"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NewInvoicePage>
|
|
||||||
<Button>
|
|
||||||
<Plus />
|
|
||||||
New Invoice
|
|
||||||
</Button>
|
|
||||||
</NewInvoicePage>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-slate-500">Total Revenue (USD)</p>
|
|
||||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
|
||||||
{formatCurrency(totalRevenue, "USD")}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
|
||||||
<DollarSign />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-slate-500">Outstanding (USD)</p>
|
|
||||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
|
||||||
{formatCurrency(outstanding, "USD")}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
|
||||||
<Clock />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-slate-500">Overdue Invoices</p>
|
|
||||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
|
||||||
{overdueCount}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-red-100 text-red-600">
|
|
||||||
<AlertCircle />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="p-2">
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{FILTERS.map((f) => {
|
|
||||||
const isActive = f === filter;
|
|
||||||
const count =
|
|
||||||
f === "All"
|
|
||||||
? invoices.length
|
|
||||||
: invoices.filter((inv) => inv.status === f).length;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={f}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setFilter(f);
|
|
||||||
setPagination({
|
|
||||||
pageIndex: 0,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className={
|
|
||||||
isActive
|
|
||||||
? "inline-flex items-center gap-2 rounded-2xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
|
|
||||||
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-primary/10 hover:text-primary"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{f}
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
isActive
|
|
||||||
? "rounded-full bg-white/20 px-2 py-0.5 text-xs"
|
|
||||||
: "rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="gap-0">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between border-b">
|
|
||||||
<div>
|
|
||||||
<CardTitle>Invoices</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Issued invoices and their payment status.
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button variant="secondary" size="sm">
|
|
||||||
<Filter />
|
|
||||||
Filter
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="px-0">
|
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
|
||||||
data={paginatedData}
|
|
||||||
status="success"
|
|
||||||
onRowClick={() => { }}
|
|
||||||
pagination={{
|
|
||||||
pageIndex: pagination.pageIndex,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
pageCount: pageCount,
|
|
||||||
totalCount: total,
|
|
||||||
}}
|
|
||||||
tableOptions={{
|
|
||||||
state: { pagination },
|
|
||||||
onPaginationChange: setPagination,
|
|
||||||
}}
|
|
||||||
containerClassName="border-b shadow-none"
|
|
||||||
footer={DataTableFooter}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: InvoiceStatus }) {
|
|
||||||
const styles: Record<InvoiceStatus, string> = {
|
|
||||||
Draft: "bg-slate-100 text-slate-600",
|
|
||||||
Sent: "bg-sky-100 text-sky-700",
|
|
||||||
Paid: "bg-emerald-100 text-emerald-700",
|
|
||||||
Overdue: "bg-red-100 text-red-700",
|
|
||||||
Cancelled: "bg-amber-100 text-amber-700",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
|
|
||||||
>
|
|
||||||
{status}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
export interface DeleteInvoiceDialogProps {
|
|
||||||
invoiceNumber: string;
|
|
||||||
onConfirm?: () => void;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DeleteInvoiceDialog({
|
|
||||||
invoiceNumber,
|
|
||||||
onConfirm,
|
|
||||||
children,
|
|
||||||
}: DeleteInvoiceDialogProps) {
|
|
||||||
return (
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent className="sm:max-w-md rounded-3xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-xl font-bold">
|
|
||||||
Void invoice?
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
This will void invoice{" "}
|
|
||||||
<span className="font-semibold text-slate-900">
|
|
||||||
{invoiceNumber}
|
|
||||||
</span>
|
|
||||||
. This action cannot be undone.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<DialogFooter className="mt-2">
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button variant="outline">Cancel</Button>
|
|
||||||
</DialogClose>
|
|
||||||
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button
|
|
||||||
onClick={onConfirm}
|
|
||||||
className="bg-red-600 text-white hover:bg-red-700"
|
|
||||||
>
|
|
||||||
Void Invoice
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Center,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Paper,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { ArrowLeft, CreditCard, Info } from "lucide-react";
|
||||||
|
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import { formatCurrency } from "@/lib/currency";
|
||||||
|
import { BORDER, INK, MUTED } from "../contracts/contract-ui";
|
||||||
|
import {
|
||||||
|
billedTo,
|
||||||
|
fmtDate,
|
||||||
|
InvoiceStatusBadge,
|
||||||
|
isPayable,
|
||||||
|
titleCase,
|
||||||
|
} from "./invoice-ui";
|
||||||
|
|
||||||
|
function MetaItem({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Text fz={11} fw={700} c={MUTED} style={{ textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text fz={14} mt={4} style={{ color: INK }}>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InvoiceDetailPage() {
|
||||||
|
const { id = "" } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { data: invoice, isLoading, isError } = useQuery(
|
||||||
|
api.invoices.get.queryOptions({ input: { id } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const payMutation = useMutation(
|
||||||
|
api.invoices.pay.mutationOptions({
|
||||||
|
onSuccess: (res) => {
|
||||||
|
const url = res.clientAction?.url;
|
||||||
|
if (url) window.location.href = url;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Center py={80}>
|
||||||
|
<Loader color="edr-green" />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !invoice) {
|
||||||
|
return (
|
||||||
|
<Box style={{ padding: "28px 32px" }}>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
leftSection={<ArrowLeft size={16} />}
|
||||||
|
onClick={() => navigate("/billing")}
|
||||||
|
mb="md"
|
||||||
|
>
|
||||||
|
Back to invoices
|
||||||
|
</Button>
|
||||||
|
<Alert color="red" title="Invoice not found">
|
||||||
|
We couldn't load this invoice. It may not exist or you may not have
|
||||||
|
access to it.
|
||||||
|
</Alert>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payable = isPayable(invoice.status);
|
||||||
|
const lines = invoice.lines ?? [];
|
||||||
|
|
||||||
|
const handlePay = () => {
|
||||||
|
const returnUrl = `${window.location.origin}/payment/success`;
|
||||||
|
const failureUrl = `${window.location.origin}/payment/failure`;
|
||||||
|
payMutation.mutate({ id, payload: { returnUrl, failureUrl } });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box style={{ padding: "28px 32px 32px" }}>
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
leftSection={<ArrowLeft size={16} />}
|
||||||
|
onClick={() => navigate("/billing")}
|
||||||
|
style={{ alignSelf: "flex-start" }}
|
||||||
|
styles={{ root: { fontWeight: 600 } }}
|
||||||
|
>
|
||||||
|
Back to invoices
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||||
|
<Group gap={12} align="center" wrap="wrap">
|
||||||
|
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
|
||||||
|
{invoice.invoiceNumber}
|
||||||
|
</Title>
|
||||||
|
<InvoiceStatusBadge status={invoice.status} />
|
||||||
|
</Group>
|
||||||
|
{payable && (
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
leftSection={<CreditCard size={16} />}
|
||||||
|
loading={payMutation.isPending}
|
||||||
|
onClick={handlePay}
|
||||||
|
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 18 } }}
|
||||||
|
>
|
||||||
|
Pay {formatCurrency(Number(invoice.totalAmount), invoice.currency)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{payMutation.isError && (
|
||||||
|
<Alert color="red" icon={<Info size={16} />} title="Payment could not be started">
|
||||||
|
Please try again, or contact support if the problem persists.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="lg">
|
||||||
|
<MetaItem label="Billed To" value={billedTo(invoice)} />
|
||||||
|
<MetaItem label="Source" value={`${titleCase(invoice.source)} · ${invoice.type}`} />
|
||||||
|
<MetaItem label="Issued" value={fmtDate(invoice.issuedAt)} />
|
||||||
|
<MetaItem label="Due" value={fmtDate(invoice.dueAt)} />
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Divider my="lg" color={BORDER} />
|
||||||
|
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Text fz={14} fw={700} c={MUTED} style={{ textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
||||||
|
Total
|
||||||
|
</Text>
|
||||||
|
<Text fz={24} fw={800} style={{ color: INK }}>
|
||||||
|
{formatCurrency(Number(invoice.totalAmount), invoice.currency)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Line items */}
|
||||||
|
<Paper
|
||||||
|
withBorder
|
||||||
|
radius="lg"
|
||||||
|
style={{ borderColor: BORDER, overflow: "hidden" }}
|
||||||
|
>
|
||||||
|
<Box px="lg" py="md" style={{ borderBottom: `1px solid ${BORDER}` }}>
|
||||||
|
<Text fz={15} fw={700} style={{ color: INK }}>
|
||||||
|
Line items
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box style={{ overflowX: "auto" }}>
|
||||||
|
<Table
|
||||||
|
verticalSpacing={12}
|
||||||
|
horizontalSpacing={20}
|
||||||
|
styles={{
|
||||||
|
th: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: MUTED,
|
||||||
|
background: "#F8FAFC",
|
||||||
|
borderBottom: `1px solid ${BORDER}`,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
},
|
||||||
|
td: { borderBottom: `1px solid ${BORDER}` },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Charge</Table.Th>
|
||||||
|
<Table.Th ta="right">Qty</Table.Th>
|
||||||
|
<Table.Th ta="right">Unit Rate</Table.Th>
|
||||||
|
<Table.Th ta="right">Amount</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{lines.length === 0 && (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={4}>
|
||||||
|
<Center py={28}>
|
||||||
|
<Text fz={13} c="dimmed">
|
||||||
|
No line items on this invoice.
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
)}
|
||||||
|
{lines.map((line) => (
|
||||||
|
<Table.Tr key={line.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz={14} fw={600} style={{ color: INK }}>
|
||||||
|
{titleCase(line.chargeType)}
|
||||||
|
</Text>
|
||||||
|
{line.description && (
|
||||||
|
<Text fz={12} c="dimmed">
|
||||||
|
{line.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right">
|
||||||
|
<Text fz={13} style={{ color: INK }}>
|
||||||
|
{Number(line.quantity)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right">
|
||||||
|
<Text fz={13} style={{ color: INK }}>
|
||||||
|
{formatCurrency(Number(line.unitRate), line.currency)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right">
|
||||||
|
<Text fz={13} fw={700} style={{ color: INK }}>
|
||||||
|
{formatCurrency(Number(line.amount), line.currency)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
537
apps/edr-freight-web/portal/src/pages/billing/InvoicesList.tsx
Normal file
537
apps/edr-freight-web/portal/src/pages/billing/InvoicesList.tsx
Normal file
@@ -0,0 +1,537 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Center,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
CreditCard,
|
||||||
|
Eye,
|
||||||
|
FileStack,
|
||||||
|
Inbox,
|
||||||
|
Receipt,
|
||||||
|
Search,
|
||||||
|
Wallet,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import { formatCurrency } from "@/lib/currency";
|
||||||
|
import {
|
||||||
|
BORDER,
|
||||||
|
GREEN,
|
||||||
|
INK,
|
||||||
|
MUTED,
|
||||||
|
StatCard,
|
||||||
|
} from "../contracts/contract-ui";
|
||||||
|
import {
|
||||||
|
billedTo,
|
||||||
|
fmtDate,
|
||||||
|
InvoiceStatusBadge,
|
||||||
|
isPayable,
|
||||||
|
PAYABLE_STATUSES,
|
||||||
|
titleCase,
|
||||||
|
} from "./invoice-ui";
|
||||||
|
|
||||||
|
const PAGE_SIZES = ["10", "25", "50"];
|
||||||
|
|
||||||
|
export default function InvoicesList() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||||
|
const [pageIndex, setPageIndex] = useState(0);
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
|
||||||
|
const { data, isLoading, isError } = useQuery(
|
||||||
|
api.invoices.listMy.queryOptions(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const all = useMemo(() => data ?? [], [data]);
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const outstanding = all.filter((i) =>
|
||||||
|
PAYABLE_STATUSES.includes(i.status),
|
||||||
|
).length;
|
||||||
|
const overdue = all.filter(
|
||||||
|
(i) => i.status === Freight.InvoiceStatus.Overdue,
|
||||||
|
).length;
|
||||||
|
return { outstanding, overdue, total: all.length };
|
||||||
|
}, [all]);
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
return all.filter((inv) => {
|
||||||
|
if (statusFilter && inv.status !== statusFilter) return false;
|
||||||
|
if (!q) return true;
|
||||||
|
return (
|
||||||
|
inv.invoiceNumber.toLowerCase().includes(q) ||
|
||||||
|
inv.source.toLowerCase().includes(q) ||
|
||||||
|
inv.sourceId.toLowerCase().includes(q) ||
|
||||||
|
billedTo(inv).toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [all, query, statusFilter]);
|
||||||
|
|
||||||
|
const total = rows.length;
|
||||||
|
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
const clampedIndex = Math.min(pageIndex, pageCount - 1);
|
||||||
|
const start = total === 0 ? 0 : clampedIndex * pageSize + 1;
|
||||||
|
const end = Math.min((clampedIndex + 1) * pageSize, total);
|
||||||
|
const pageRows = rows.slice(clampedIndex * pageSize, clampedIndex * pageSize + pageSize);
|
||||||
|
|
||||||
|
const resetPage = () => setPageIndex(0);
|
||||||
|
const goToPage = (i: number) =>
|
||||||
|
setPageIndex(Math.max(0, Math.min(i, pageCount - 1)));
|
||||||
|
|
||||||
|
const hasFilters = !!query || !!statusFilter;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box style={{ padding: "28px 32px 32px" }}>
|
||||||
|
<Stack gap="lg">
|
||||||
|
{/* Header */}
|
||||||
|
<Group justify="space-between" align="center" wrap="wrap" gap="md">
|
||||||
|
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
|
||||||
|
Invoices
|
||||||
|
</Title>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Summary strip */}
|
||||||
|
<Group gap="md" wrap="wrap" align="stretch">
|
||||||
|
<StatCard
|
||||||
|
label="Outstanding"
|
||||||
|
hint="awaiting payment"
|
||||||
|
value={stats.outstanding}
|
||||||
|
icon={Wallet}
|
||||||
|
color="edr-accent"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Overdue"
|
||||||
|
hint="past due date"
|
||||||
|
value={stats.overdue}
|
||||||
|
icon={AlertTriangle}
|
||||||
|
color="red"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Total invoices"
|
||||||
|
value={stats.total}
|
||||||
|
icon={FileStack}
|
||||||
|
color="violet"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Search + filters */}
|
||||||
|
<Paper withBorder radius="lg" p="sm" style={{ borderColor: BORDER }}>
|
||||||
|
<Group gap={10} wrap="wrap" align="center">
|
||||||
|
<TextInput
|
||||||
|
placeholder="Search by number, source or reference…"
|
||||||
|
leftSection={<Search size={16} />}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.currentTarget.value);
|
||||||
|
resetPage();
|
||||||
|
}}
|
||||||
|
radius="md"
|
||||||
|
styles={{ input: { height: 42 } }}
|
||||||
|
style={{ flex: 1, minWidth: 220, maxWidth: 380 }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="Any status"
|
||||||
|
data={[
|
||||||
|
{ value: Freight.InvoiceStatus.Pending, label: "Due" },
|
||||||
|
{ value: Freight.InvoiceStatus.Overdue, label: "Overdue" },
|
||||||
|
{ value: Freight.InvoiceStatus.Paid, label: "Paid" },
|
||||||
|
{ value: Freight.InvoiceStatus.Draft, label: "Draft" },
|
||||||
|
{ value: Freight.InvoiceStatus.Cancelled, label: "Cancelled" },
|
||||||
|
{ value: Freight.InvoiceStatus.Refunded, label: "Refunded" },
|
||||||
|
]}
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(v) => {
|
||||||
|
setStatusFilter(v);
|
||||||
|
resetPage();
|
||||||
|
}}
|
||||||
|
clearable
|
||||||
|
radius="md"
|
||||||
|
comboboxProps={{ withinPortal: true }}
|
||||||
|
style={{ width: 160 }}
|
||||||
|
styles={{ input: { height: 42 } }}
|
||||||
|
aria-label="Filter by status"
|
||||||
|
/>
|
||||||
|
{hasFilters && (
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<X size={14} />}
|
||||||
|
onClick={() => {
|
||||||
|
setQuery("");
|
||||||
|
setStatusFilter(null);
|
||||||
|
resetPage();
|
||||||
|
}}
|
||||||
|
styles={{ root: { fontWeight: 600 } }}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<Paper
|
||||||
|
withBorder
|
||||||
|
radius="lg"
|
||||||
|
style={{ borderColor: BORDER, overflow: "hidden" }}
|
||||||
|
>
|
||||||
|
<Box style={{ overflowX: "auto" }}>
|
||||||
|
<Table
|
||||||
|
verticalSpacing={14}
|
||||||
|
horizontalSpacing={20}
|
||||||
|
highlightOnHover
|
||||||
|
highlightOnHoverColor="#F4FBF8"
|
||||||
|
styles={{
|
||||||
|
th: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: MUTED,
|
||||||
|
background: "#F8FAFC",
|
||||||
|
borderBottom: `1px solid ${BORDER}`,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
position: "sticky",
|
||||||
|
top: 0,
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
tr: { transition: "background-color 120ms ease" },
|
||||||
|
td: {
|
||||||
|
borderBottom: `1px solid ${BORDER}`,
|
||||||
|
verticalAlign: "middle",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Invoice</Table.Th>
|
||||||
|
<Table.Th>Billed To</Table.Th>
|
||||||
|
<Table.Th>Source</Table.Th>
|
||||||
|
<Table.Th ta="right">Amount</Table.Th>
|
||||||
|
<Table.Th>Issued</Table.Th>
|
||||||
|
<Table.Th>Due</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th ta="right">Action</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{isLoading && (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={8}>
|
||||||
|
<Center py={48}>
|
||||||
|
<Loader color="edr-green" size="sm" />
|
||||||
|
</Center>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && isError && (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={8}>
|
||||||
|
<Center py={48}>
|
||||||
|
<Text fz={13} c="red">
|
||||||
|
Failed to load invoices. Please try again.
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !isError && pageRows.length === 0 && (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={8}>
|
||||||
|
<Stack align="center" gap={8} py={48}>
|
||||||
|
<Inbox size={26} color={MUTED} style={{ opacity: 0.5 }} />
|
||||||
|
<Text fz={13} c="dimmed">
|
||||||
|
{hasFilters
|
||||||
|
? "No invoices match your filters."
|
||||||
|
: "No invoices yet."}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading &&
|
||||||
|
!isError &&
|
||||||
|
pageRows.map((inv) => {
|
||||||
|
const payable = isPayable(inv.status);
|
||||||
|
return (
|
||||||
|
<Table.Tr
|
||||||
|
key={inv.id}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onClick={() => navigate(`/billing/${inv.id}`)}
|
||||||
|
>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap={10} wrap="nowrap" align="center">
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
borderRadius: 9,
|
||||||
|
background: "#E6F7EF",
|
||||||
|
color: GREEN,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Receipt size={16} />
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text fz={14} fw={700} style={{ color: INK }}>
|
||||||
|
{inv.invoiceNumber}
|
||||||
|
</Text>
|
||||||
|
<Text fz={12} c="dimmed">
|
||||||
|
{titleCase(inv.type)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz={13} style={{ color: INK }}>
|
||||||
|
{billedTo(inv)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz={13} style={{ color: INK }}>
|
||||||
|
{titleCase(inv.source)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right">
|
||||||
|
<Text fz={13} fw={700} style={{ color: INK }}>
|
||||||
|
{formatCurrency(Number(inv.totalAmount), inv.currency)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text
|
||||||
|
fz={13}
|
||||||
|
c={inv.issuedAt ? undefined : "dimmed"}
|
||||||
|
style={{ color: inv.issuedAt ? INK : undefined }}
|
||||||
|
>
|
||||||
|
{fmtDate(inv.issuedAt)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz={13} style={{ color: INK }}>
|
||||||
|
{fmtDate(inv.dueAt)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<InvoiceStatusBadge status={inv.status} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group justify="flex-end" gap={8} wrap="nowrap">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
radius="md"
|
||||||
|
h={34}
|
||||||
|
variant={payable ? "filled" : "light"}
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={
|
||||||
|
payable ? (
|
||||||
|
<CreditCard size={15} />
|
||||||
|
) : (
|
||||||
|
<Eye size={15} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate(`/billing/${inv.id}`);
|
||||||
|
}}
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 13,
|
||||||
|
paddingInline: 14,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
boxShadow: payable
|
||||||
|
? "0 1px 2px rgba(14,163,113,0.25)"
|
||||||
|
: "none",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{payable ? "Pay" : "View"}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Pagination footer */}
|
||||||
|
{!isLoading && !isError && total > 0 && (
|
||||||
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
align="center"
|
||||||
|
wrap="wrap"
|
||||||
|
gap="md"
|
||||||
|
px={20}
|
||||||
|
py={14}
|
||||||
|
style={{ borderTop: `1px solid ${BORDER}`, background: "#FCFDFE" }}
|
||||||
|
>
|
||||||
|
<Group gap={10} align="center">
|
||||||
|
<Text fz={13} c="dimmed">
|
||||||
|
Rows
|
||||||
|
</Text>
|
||||||
|
<Select
|
||||||
|
data={PAGE_SIZES}
|
||||||
|
value={String(pageSize)}
|
||||||
|
onChange={(v) => {
|
||||||
|
if (!v) return;
|
||||||
|
setPageSize(Number(v));
|
||||||
|
setPageIndex(0);
|
||||||
|
}}
|
||||||
|
radius="md"
|
||||||
|
size="xs"
|
||||||
|
comboboxProps={{ withinPortal: true }}
|
||||||
|
style={{ width: 76 }}
|
||||||
|
allowDeselect={false}
|
||||||
|
/>
|
||||||
|
<Text fz={13} c="dimmed">
|
||||||
|
{start}–{end} of {total}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap={6} align="center">
|
||||||
|
<PagerButton
|
||||||
|
icon={<ChevronLeft size={16} />}
|
||||||
|
disabled={clampedIndex === 0}
|
||||||
|
onClick={() => goToPage(clampedIndex - 1)}
|
||||||
|
ariaLabel="Previous page"
|
||||||
|
/>
|
||||||
|
{pageNumbers(clampedIndex, pageCount).map((p, i) =>
|
||||||
|
p === "…" ? (
|
||||||
|
<Text key={`gap-${i}`} fz={13} c="dimmed" px={4}>
|
||||||
|
…
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<PageChip
|
||||||
|
key={p}
|
||||||
|
page={p}
|
||||||
|
active={p === clampedIndex}
|
||||||
|
onClick={() => goToPage(p)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
<PagerButton
|
||||||
|
icon={<ChevronRight size={16} />}
|
||||||
|
disabled={clampedIndex >= pageCount - 1}
|
||||||
|
onClick={() => goToPage(clampedIndex + 1)}
|
||||||
|
ariaLabel="Next page"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact page-number window with ellipses: 1 … 4 5 6 … 12. */
|
||||||
|
function pageNumbers(active: number, count: number): (number | "…")[] {
|
||||||
|
if (count <= 7) return Array.from({ length: count }, (_, i) => i);
|
||||||
|
const out: (number | "…")[] = [0];
|
||||||
|
const lo = Math.max(1, active - 1);
|
||||||
|
const hi = Math.min(count - 2, active + 1);
|
||||||
|
if (lo > 1) out.push("…");
|
||||||
|
for (let i = lo; i <= hi; i++) out.push(i);
|
||||||
|
if (hi < count - 2) out.push("…");
|
||||||
|
out.push(count - 1);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageChip({
|
||||||
|
page,
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="button"
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
minWidth: 32,
|
||||||
|
height: 32,
|
||||||
|
padding: "0 8px",
|
||||||
|
borderRadius: 9,
|
||||||
|
border: `1px solid ${active ? GREEN : BORDER}`,
|
||||||
|
background: active ? GREEN : "#FFFFFF",
|
||||||
|
color: active ? "#FFFFFF" : INK,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: active ? 700 : 600,
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all 120ms ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{page + 1}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PagerButton({
|
||||||
|
icon,
|
||||||
|
disabled,
|
||||||
|
onClick,
|
||||||
|
ariaLabel,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
disabled: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
ariaLabel: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="button"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: 9,
|
||||||
|
border: `1px solid ${BORDER}`,
|
||||||
|
background: "#FFFFFF",
|
||||||
|
color: disabled ? "#C2CCD6" : INK,
|
||||||
|
cursor: disabled ? "not-allowed" : "pointer",
|
||||||
|
opacity: disabled ? 0.6 : 1,
|
||||||
|
transition: "all 120ms ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
import { Calendar, DollarSign, Hash } from "lucide-react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
|
|
||||||
import { customers } from "../customers/customers.mock";
|
|
||||||
import { bookings } from "../bookings/bookings.mock";
|
|
||||||
import type { Currency, InvoiceStatus } from "./invoices.mock";
|
|
||||||
|
|
||||||
export interface InvoiceFormData {
|
|
||||||
number?: string;
|
|
||||||
customerId?: number;
|
|
||||||
bookingReference?: string;
|
|
||||||
amount?: number;
|
|
||||||
currency?: Currency;
|
|
||||||
status?: InvoiceStatus;
|
|
||||||
issueDate?: string;
|
|
||||||
dueDate?: string;
|
|
||||||
notes?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NewInvoicePageProps {
|
|
||||||
mode?: "create" | "edit";
|
|
||||||
invoice?: InvoiceFormData;
|
|
||||||
children?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectClass =
|
|
||||||
"flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20";
|
|
||||||
|
|
||||||
export default function NewInvoicePage({
|
|
||||||
mode = "create",
|
|
||||||
invoice,
|
|
||||||
children,
|
|
||||||
}: NewInvoicePageProps = {}) {
|
|
||||||
const isEdit = mode === "edit";
|
|
||||||
const title = isEdit ? "Edit Invoice" : "New Invoice";
|
|
||||||
const description = isEdit
|
|
||||||
? "Update invoice details."
|
|
||||||
: "Create a new invoice for a customer booking.";
|
|
||||||
const submitLabel = isEdit ? "Save Changes" : "Create Invoice";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
{children ?? <Button>{isEdit ? "Edit" : "New Invoice"}</Button>}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl rounded-3xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
|
|
||||||
<DialogDescription>{description}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="grid gap-5 py-4 md:grid-cols-2">
|
|
||||||
{/* Invoice Number */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Invoice Number *</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Hash className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
defaultValue={invoice?.number ?? ""}
|
|
||||||
placeholder="e.g. INV-2026-0001"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Status</Label>
|
|
||||||
<select
|
|
||||||
defaultValue={invoice?.status ?? "Draft"}
|
|
||||||
className={selectClass}
|
|
||||||
>
|
|
||||||
<option>Draft</option>
|
|
||||||
<option>Sent</option>
|
|
||||||
<option>Paid</option>
|
|
||||||
<option>Overdue</option>
|
|
||||||
<option>Cancelled</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Customer */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Customer *</Label>
|
|
||||||
<select
|
|
||||||
defaultValue={invoice?.customerId ?? ""}
|
|
||||||
className={selectClass}
|
|
||||||
>
|
|
||||||
<option value="" disabled>
|
|
||||||
Select customer
|
|
||||||
</option>
|
|
||||||
{customers.map((c) => (
|
|
||||||
<option key={c.id} value={c.id}>
|
|
||||||
{c.company}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Booking */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Booking Reference</Label>
|
|
||||||
<select
|
|
||||||
defaultValue={invoice?.bookingReference ?? ""}
|
|
||||||
className={selectClass}
|
|
||||||
>
|
|
||||||
<option value="">No linked booking</option>
|
|
||||||
{bookings.map((b) => (
|
|
||||||
<option key={b.id} value={b.reference}>
|
|
||||||
{b.reference} — {b.customer}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Amount */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Amount *</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<DollarSign className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
step="0.01"
|
|
||||||
defaultValue={invoice?.amount ?? 0}
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Currency */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Currency</Label>
|
|
||||||
<select
|
|
||||||
defaultValue={invoice?.currency ?? "USD"}
|
|
||||||
className={selectClass}
|
|
||||||
>
|
|
||||||
<option>USD</option>
|
|
||||||
<option>ETB</option>
|
|
||||||
<option>DJF</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Issue Date */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Issue Date *</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Calendar className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
defaultValue={invoice?.issueDate ?? ""}
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Due Date */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Due Date *</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Calendar className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
defaultValue={invoice?.dueDate ?? ""}
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Notes */}
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
|
||||||
<Label>Notes</Label>
|
|
||||||
<Textarea
|
|
||||||
defaultValue={invoice?.notes ?? ""}
|
|
||||||
placeholder="Payment terms, references, etc."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<Button variant="outline">Cancel</Button>
|
|
||||||
<Button className="bg-[#10B981] text-white hover:bg-[#10B981]/90">
|
|
||||||
{submitLabel}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
60
apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx
Normal file
60
apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { Box } from "@mantine/core";
|
||||||
|
import { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import type { PortalInvoice } from "@/services/invoices.service";
|
||||||
|
|
||||||
|
/** Statuses a customer can still pay. */
|
||||||
|
export const PAYABLE_STATUSES: Freight.InvoiceStatus[] = [
|
||||||
|
Freight.InvoiceStatus.Pending,
|
||||||
|
Freight.InvoiceStatus.Overdue,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const isPayable = (status: Freight.InvoiceStatus) =>
|
||||||
|
PAYABLE_STATUSES.includes(status);
|
||||||
|
|
||||||
|
const STATUS_STYLE: Record<
|
||||||
|
Freight.InvoiceStatus,
|
||||||
|
{ label: string; bg: string; fg: string }
|
||||||
|
> = {
|
||||||
|
[Freight.InvoiceStatus.Draft]: { label: "Draft", bg: "#EEF2F6", fg: "#64748B" },
|
||||||
|
[Freight.InvoiceStatus.Pending]: { label: "Due", bg: "#FEF3E2", fg: "#B45309" },
|
||||||
|
[Freight.InvoiceStatus.Paid]: { label: "Paid", bg: "#E6F7EF", fg: "#0A6F4D" },
|
||||||
|
[Freight.InvoiceStatus.Overdue]: { label: "Overdue", bg: "#FDECEC", fg: "#C0392B" },
|
||||||
|
[Freight.InvoiceStatus.Cancelled]: { label: "Cancelled", bg: "#EEF2F6", fg: "#64748B" },
|
||||||
|
[Freight.InvoiceStatus.Refunded]: { label: "Refunded", bg: "#EAF1FB", fg: "#2563EB" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InvoiceStatusBadge({ status }: { status: Freight.InvoiceStatus }) {
|
||||||
|
const s = STATUS_STYLE[status] ?? { label: status, bg: "#EEF2F6", fg: "#64748B" };
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "4px 10px",
|
||||||
|
borderRadius: 999,
|
||||||
|
background: s.bg,
|
||||||
|
color: s.fg,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const titleCase = (v: string) =>
|
||||||
|
v ? v.charAt(0).toUpperCase() + v.slice(1).toLowerCase() : "—";
|
||||||
|
|
||||||
|
/** Best label for who an invoice is billed to (profile ref → profile type → company). */
|
||||||
|
export function billedTo(inv: PortalInvoice): string {
|
||||||
|
const profile = inv.companyProfile;
|
||||||
|
if (profile?.reference) return profile.reference;
|
||||||
|
if (profile?.type) return titleCase(profile.type);
|
||||||
|
return inv.company?.name ?? "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fmtDate = (v: string | null | undefined) =>
|
||||||
|
v ? new Date(v).toLocaleDateString() : "—";
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import { customers } from "../customers/customers.mock";
|
|
||||||
import { bookings } from "../bookings/bookings.mock";
|
|
||||||
|
|
||||||
export type InvoiceStatus =
|
|
||||||
| "Draft"
|
|
||||||
| "Sent"
|
|
||||||
| "Paid"
|
|
||||||
| "Overdue"
|
|
||||||
| "Cancelled";
|
|
||||||
|
|
||||||
export type Currency = "USD" | "ETB" | "DJF";
|
|
||||||
|
|
||||||
export interface Invoice {
|
|
||||||
id: number;
|
|
||||||
number: string;
|
|
||||||
customerId: number;
|
|
||||||
customer: string;
|
|
||||||
bookingReference: string;
|
|
||||||
amount: number;
|
|
||||||
currency: Currency;
|
|
||||||
status: InvoiceStatus;
|
|
||||||
issueDate: string;
|
|
||||||
dueDate: string;
|
|
||||||
paidDate: string | null;
|
|
||||||
notes: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const statuses: InvoiceStatus[] = [
|
|
||||||
"Draft",
|
|
||||||
"Sent",
|
|
||||||
"Paid",
|
|
||||||
"Overdue",
|
|
||||||
"Cancelled",
|
|
||||||
];
|
|
||||||
const currencies: Currency[] = ["USD", "ETB", "DJF"];
|
|
||||||
|
|
||||||
export const invoices: Invoice[] = Array.from({ length: 24 }, (_, i) => {
|
|
||||||
const customer = customers[i % customers.length] as (typeof customers)[number];
|
|
||||||
const booking = bookings[i % bookings.length] as (typeof bookings)[number];
|
|
||||||
const id = i + 1;
|
|
||||||
const issue = new Date(2026, 3, 1 + (i % 28));
|
|
||||||
const due = new Date(issue);
|
|
||||||
due.setDate(due.getDate() + 30);
|
|
||||||
const status = statuses[i % statuses.length] as InvoiceStatus;
|
|
||||||
const currency = currencies[i % currencies.length] as Currency;
|
|
||||||
const baseAmount = 5000 + (i * 1234) % 25000;
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
number: `INV-2026-${String(id).padStart(4, "0")}`,
|
|
||||||
customerId: customer.id,
|
|
||||||
customer: customer.company,
|
|
||||||
bookingReference: booking.reference,
|
|
||||||
amount: Math.round(baseAmount * 100) / 100,
|
|
||||||
currency,
|
|
||||||
status,
|
|
||||||
issueDate: issue.toISOString().slice(0, 10),
|
|
||||||
dueDate: due.toISOString().slice(0, 10),
|
|
||||||
paidDate:
|
|
||||||
status === "Paid"
|
|
||||||
? new Date(due.getTime() - 86400000 * (i % 7))
|
|
||||||
.toISOString()
|
|
||||||
.slice(0, 10)
|
|
||||||
: null,
|
|
||||||
notes:
|
|
||||||
i % 3 === 0
|
|
||||||
? "Net 30 payment terms."
|
|
||||||
: i % 3 === 1
|
|
||||||
? "Bank transfer preferred."
|
|
||||||
: "Payment due upon receipt.",
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export function getInvoiceById(id: number | string): Invoice | undefined {
|
|
||||||
const numericId = typeof id === "string" ? Number(id) : id;
|
|
||||||
return invoices.find((inv) => inv.id === numericId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatCurrency(amount: number, currency: Currency): string {
|
|
||||||
const symbols: Record<Currency, string> = {
|
|
||||||
USD: "$",
|
|
||||||
ETB: "Br",
|
|
||||||
DJF: "DJF",
|
|
||||||
};
|
|
||||||
return `${symbols[currency]} ${amount.toLocaleString(undefined, {
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
})}`;
|
|
||||||
}
|
|
||||||
@@ -30,6 +30,12 @@ import {
|
|||||||
IntentStatus,
|
IntentStatus,
|
||||||
} from "./payments.service";
|
} from "./payments.service";
|
||||||
import { consignmentsService } from "./consignments.service";
|
import { consignmentsService } from "./consignments.service";
|
||||||
|
import {
|
||||||
|
invoicesService,
|
||||||
|
PortalInvoice,
|
||||||
|
PortalInvoiceDetail,
|
||||||
|
PayInvoicePayload,
|
||||||
|
} from "./invoices.service";
|
||||||
import { trackingService } from "./tracking.service";
|
import { trackingService } from "./tracking.service";
|
||||||
import { fileUploadSettingsService } from "./fileUploadSettings.service";
|
import { fileUploadSettingsService } from "./fileUploadSettings.service";
|
||||||
import { dropdownSettingsService } from "./dropdownSettings.service";
|
import { dropdownSettingsService } from "./dropdownSettings.service";
|
||||||
@@ -597,4 +603,23 @@ export const api = {
|
|||||||
({ optionId }) => dropdownSettingsService.removeOption(optionId),
|
({ optionId }) => dropdownSettingsService.removeOption(optionId),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
invoices: {
|
||||||
|
listMy: endpoint<void, PortalInvoice[]>(
|
||||||
|
"invoices",
|
||||||
|
"listMy",
|
||||||
|
invoicesService.listMy,
|
||||||
|
),
|
||||||
|
|
||||||
|
get: endpoint<{ id: string }, PortalInvoiceDetail>(
|
||||||
|
"invoices",
|
||||||
|
"get",
|
||||||
|
({ id }) => invoicesService.get(id),
|
||||||
|
),
|
||||||
|
|
||||||
|
pay: endpoint<
|
||||||
|
{ id: string; payload?: PayInvoicePayload },
|
||||||
|
InitiateResponse
|
||||||
|
>("invoices", "pay", ({ id, payload }) => invoicesService.pay(id, payload)),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
89
apps/edr-freight-web/portal/src/services/invoices.service.ts
Normal file
89
apps/edr-freight-web/portal/src/services/invoices.service.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
|
import { client } from "../utils/api";
|
||||||
|
import type { InitiateResponse } from "./payments.service";
|
||||||
|
|
||||||
|
const B = URL_CONSTANTS.BILLING;
|
||||||
|
|
||||||
|
export interface InvoiceCompany {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvoiceCompanyProfile {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
reference: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A customer-facing invoice row, as returned by `GET /billing/my-invoices`.
|
||||||
|
* Mirrors the freight `Invoice` entity (the shared `Freight.IInvoice` type is
|
||||||
|
* stale and intentionally not reused here).
|
||||||
|
*/
|
||||||
|
export interface PortalInvoice {
|
||||||
|
id: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
totalAmount: number;
|
||||||
|
currency: string;
|
||||||
|
status: Freight.InvoiceStatus;
|
||||||
|
/** Originating subsystem: booking / warehouse / demurrage. */
|
||||||
|
source: string;
|
||||||
|
sourceId: string;
|
||||||
|
/** What the invoice bills for (e.g. PREPAID). */
|
||||||
|
type: string;
|
||||||
|
issuedAt: string | null;
|
||||||
|
dueAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
company?: InvoiceCompany;
|
||||||
|
companyProfile?: InvoiceCompanyProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvoiceLine {
|
||||||
|
id: string;
|
||||||
|
chargeType: string;
|
||||||
|
description?: string | null;
|
||||||
|
quantity: number;
|
||||||
|
unitRate: number;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PortalInvoiceDetail extends PortalInvoice {
|
||||||
|
lines: InvoiceLine[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PayInvoicePayload {
|
||||||
|
method?: string;
|
||||||
|
platform?: "web" | "mobile";
|
||||||
|
payerAccount?: string;
|
||||||
|
returnUrl?: string;
|
||||||
|
failureUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const invoicesService = {
|
||||||
|
/** Every invoice billed to the signed-in customer's company, newest first. */
|
||||||
|
listMy: async (): Promise<PortalInvoice[]> => {
|
||||||
|
const { data } = await client.get(B.MY_INVOICES);
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** One of the customer's invoices, with its line items. */
|
||||||
|
get: async (id: string): Promise<PortalInvoiceDetail> => {
|
||||||
|
const { data } = await client.get(B.MY_INVOICE_BY_ID(id));
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Initiate gateway payment for an open invoice; returns the client action. */
|
||||||
|
pay: async (
|
||||||
|
id: string,
|
||||||
|
payload: PayInvoicePayload = {},
|
||||||
|
): Promise<InitiateResponse> => {
|
||||||
|
const { data } = await client.post(B.PAY_INVOICE(id), {
|
||||||
|
platform: "web",
|
||||||
|
...payload,
|
||||||
|
});
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user