From 6d40d185d6315dc345ec7c43263b188c7a4b8260 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 29 Jun 2026 13:54:14 +0000 Subject: [PATCH] feat: setup the invoice page in the portal --- apps/edr-freight-web/portal/src/App.tsx | 8 +- .../portal/src/constants/URLS.ts | 6 + .../portal/src/constants/apiConfig.ts | 4 +- .../portal/src/lib/currency.ts | 23 + .../portal/src/lib/currentCustomer.ts | 20 - .../src/pages/MyPortalPage/MyPortalPage.tsx | 4 +- .../components/FreightVolumeSection.tsx | 4 +- .../components/InvoicesSection.tsx | 99 ++-- .../MyPortalPage/components/StatsSection.tsx | 2 +- .../src/pages/MyPortalPage/constants.ts | 15 +- .../portal/src/pages/MyPortalPage/hooks.ts | 14 +- .../portal/src/pages/billing/BillingPage.tsx | 382 ------------- .../src/pages/billing/DeleteInvoiceDialog.tsx | 63 -- .../src/pages/billing/InvoiceDetailPage.tsx | 247 ++++++++ .../portal/src/pages/billing/InvoicesList.tsx | 537 ++++++++++++++++++ .../src/pages/billing/NewInvoicePage.tsx | 202 ------- .../portal/src/pages/billing/invoice-ui.tsx | 60 ++ .../portal/src/pages/billing/invoices.mock.ts | 88 --- .../portal/src/services/api.ts | 25 + .../portal/src/services/invoices.service.ts | 89 +++ 20 files changed, 1058 insertions(+), 834 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/lib/currency.ts delete mode 100644 apps/edr-freight-web/portal/src/lib/currentCustomer.ts delete mode 100644 apps/edr-freight-web/portal/src/pages/billing/BillingPage.tsx delete mode 100644 apps/edr-freight-web/portal/src/pages/billing/DeleteInvoiceDialog.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/billing/InvoicesList.tsx delete mode 100644 apps/edr-freight-web/portal/src/pages/billing/NewInvoicePage.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx delete mode 100644 apps/edr-freight-web/portal/src/pages/billing/invoices.mock.ts create mode 100644 apps/edr-freight-web/portal/src/services/invoices.service.ts diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 8ce1365c5..05c942290 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -31,7 +31,8 @@ import LoginPage from "./pages/accounts/LoginPage"; import SetPasswordPage from "./pages/accounts/SetPasswordPage"; import SignupPage from "./pages/accounts/SignupPage"; 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 BookingDetailPage from "./pages/bookings/BookingDetailPage"; import EditBookingPage from "./pages/bookings/EditBookingPage"; @@ -188,7 +189,7 @@ const sidebarItems: SidebarItem[] = [ icon: , }, { - label: "Billing", + label: "Invoices", href: "/billing", icon: , }, @@ -285,7 +286,8 @@ const App = () => { /> } /> } /> - } /> + } /> + } /> {/* Profile was merged into Settings — keep old links working. */} `/api/payments/intents/${bookingId}`, 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`, + }, }; diff --git a/apps/edr-freight-web/portal/src/constants/apiConfig.ts b/apps/edr-freight-web/portal/src/constants/apiConfig.ts index 1b070d87d..a24cb4a6d 100644 --- a/apps/edr-freight-web/portal/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/portal/src/constants/apiConfig.ts @@ -1,5 +1,5 @@ -export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; -// export const API_BASE_URL = 'http://localhost:3001'; +// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; +export const API_BASE_URL = 'http://localhost:3001'; /** * URL that streams an uploaded file through the API by its UUID. Routes the diff --git a/apps/edr-freight-web/portal/src/lib/currency.ts b/apps/edr-freight-web/portal/src/lib/currency.ts new file mode 100644 index 000000000..d41b4edda --- /dev/null +++ b/apps/edr-freight-web/portal/src/lib/currency.ts @@ -0,0 +1,23 @@ +/** Currency code carried on invoices / dashboard figures (ETB, USD, DJF, …). */ +export type Currency = string; + +const SYMBOLS: Record = { + 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, + })}`; +} diff --git a/apps/edr-freight-web/portal/src/lib/currentCustomer.ts b/apps/edr-freight-web/portal/src/lib/currentCustomer.ts deleted file mode 100644 index 47fc1efa6..000000000 --- a/apps/edr-freight-web/portal/src/lib/currentCustomer.ts +++ /dev/null @@ -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); -} diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx index cb350916d..fccc4d98f 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx @@ -1,5 +1,5 @@ -import type { Currency } from "@/pages/billing/invoices.mock"; -import { formatCurrency } from "@/pages/billing/invoices.mock"; +import type { Currency } from "@/lib/currency"; +import { formatCurrency } from "@/lib/currency"; import { Group, Grid, Select, Stack } from "@mantine/core"; import { useState } from "react"; import { useNavigate } from "react-router-dom"; diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/FreightVolumeSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/FreightVolumeSection.tsx index 27b74cb6f..c84f94c35 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/FreightVolumeSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/FreightVolumeSection.tsx @@ -1,7 +1,7 @@ import { Box, Group, Skeleton, Text } from "@mantine/core"; import { memo } from "react"; -import type { Currency } from "@/pages/billing/invoices.mock"; -import { formatCurrency } from "@/pages/billing/invoices.mock"; +import type { Currency } from "@/lib/currency"; +import { formatCurrency } from "@/lib/currency"; import { formatPct } from "../constants"; import { Card } from "./Card"; diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/InvoicesSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/InvoicesSection.tsx index 3ebb9606f..22362c816 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/InvoicesSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/InvoicesSection.tsx @@ -1,7 +1,7 @@ -import type { Currency, InvoiceStatus } from "@/pages/billing/invoices.mock"; -import { formatCurrency } from "@/pages/billing/invoices.mock"; +import { formatCurrency } from "@/lib/currency"; +import type { PortalInvoice } from "@/services/invoices.service"; 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 { memo } from "react"; import { Link } from "react-router-dom"; @@ -10,26 +10,22 @@ import { Card } from "./Card"; import { EmptyState } from "./EmptyState"; interface InvoicesSectionProps { - invoices: Array<{ - id: number; - number: string; - bookingReference: string; - amount: number; - currency: Currency; - status: InvoiceStatus; - dueDate: string; - paidDate: string | null; - }>; + invoices: PortalInvoice[]; } +const titleCase = (v: string) => + v ? v.charAt(0).toUpperCase() + v.slice(1).toLowerCase() : ""; + export const InvoicesSection = memo(function InvoicesSection({ invoices, }: InvoicesSectionProps) { 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( - (sum, inv) => sum + inv.amount, + (sum, inv) => sum + Number(inv.totalAmount), 0, ); @@ -56,14 +52,9 @@ export const InvoicesSection = memo(function InvoicesSection({ {formatCurrency(totalOutstanding || 0, "ETB")} - + - {outstandingInvoices.length || 2} invoices unpaid + {outstandingInvoices.length} invoices unpaid {invoices.map((invoice, i) => { const badge = INVOICE_BADGE[invoice.status]; - const dueText = - invoice.status === "Paid" - ? `Paid ${format(new Date(invoice.paidDate ?? invoice.dueDate), "MMM d")}` - : invoice.status === "Overdue" - ? "Overdue 3 days" - : `Due ${invoice.dueDate}`; - const DueIcon = - invoice.status === "Paid" ? CheckCircle2 : Clock3; - const dueIconColor = - invoice.status === "Paid" - ? cv("edr-green.5") - : cv("edr-muted"); + const isPaid = invoice.status === Freight.InvoiceStatus.Paid; + const isOverdue = invoice.status === Freight.InvoiceStatus.Overdue; + const dueText = isPaid + ? "Paid" + : isOverdue + ? "Overdue" + : `Due ${new Date(invoice.dueAt).toLocaleDateString()}`; + const DueIcon = isPaid ? CheckCircle2 : Clock3; + const dueIconColor = isPaid ? cv("edr-green.5") : cv("edr-muted"); return ( {i > 0 && } - + - {invoice.number} + {invoice.invoiceNumber} - {invoice.bookingReference} + {titleCase(invoice.source)} · {titleCase(invoice.type)} - {formatCurrency(invoice.amount, invoice.currency)} + {formatCurrency( + Number(invoice.totalAmount), + invoice.currency, + )} - + {dueText} - - - {badge.label} - - + {badge && ( + + + {badge.label} + + + )} diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx index dae90842b..3ddd9be4d 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx @@ -1,4 +1,4 @@ -import { formatCurrency } from "@/pages/billing/invoices.mock"; +import { formatCurrency } from "@/lib/currency"; import { SimpleGrid } from "@mantine/core"; import { CheckCircle2, Clock3, Layers, Truck, Wallet } from "lucide-react"; import { memo } from "react"; diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts index 70e58f77c..ea5567ab8 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts @@ -12,7 +12,7 @@ import { Wallet, type LucideIcon, } from "lucide-react"; -import type { InvoiceStatus } from "@/pages/billing/invoices.mock"; +import { Freight } from "@edr/types"; export const cv = (token: string) => { const [name, shade] = token.split("."); @@ -514,12 +514,13 @@ export const ACTION_PROPS: Record< }; export const INVOICE_BADGE: Record< - InvoiceStatus, + Freight.InvoiceStatus, { label: string; bg: string; text: string } > = { - Draft: { label: "Draft", bg: "edr-slate-soft", text: "edr-slate" }, - Sent: { label: "Due soon", bg: "edr-amber-soft", text: "edr-amber-text" }, - Paid: { label: "Paid", bg: "edr-soft", text: "edr-green.7" }, - Overdue: { label: "Overdue", bg: "edr-red-soft", text: "edr-red" }, - Cancelled: { label: "Cancelled", bg: "edr-slate-soft", text: "edr-slate" }, + [Freight.InvoiceStatus.Draft]: { label: "Draft", bg: "edr-slate-soft", text: "edr-slate" }, + [Freight.InvoiceStatus.Pending]: { label: "Due soon", bg: "edr-amber-soft", text: "edr-amber-text" }, + [Freight.InvoiceStatus.Paid]: { label: "Paid", bg: "edr-soft", text: "edr-green.7" }, + [Freight.InvoiceStatus.Overdue]: { label: "Overdue", bg: "edr-red-soft", text: "edr-red" }, + [Freight.InvoiceStatus.Cancelled]: { label: "Cancelled", bg: "edr-slate-soft", text: "edr-slate" }, + [Freight.InvoiceStatus.Refunded]: { label: "Refunded", bg: "edr-blue-soft", text: "edr-blue" }, }; diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts index 8c1bb6b04..08a3ec97e 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts @@ -1,13 +1,14 @@ import { useQuery } from "@tanstack/react-query"; -import { useMemo } from "react"; +import { Freight } from "@edr/types"; import useAuth from "@/hooks/useAuth"; -import { getMyInvoices } from "@/lib/currentCustomer"; import { api } from "@/services/api"; import { ACTIVE_STATUSES } from "./constants"; export function useMyPortalData(selectedProfileId?: string) { 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 ?? []; @@ -59,11 +60,13 @@ export function useMyPortalData(selectedProfileId?: string) { ).length; 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( - (sum, inv) => sum + inv.amount, + (sum, inv) => sum + Number(inv.totalAmount), 0, ); @@ -91,6 +94,7 @@ export function useMyPortalData(selectedProfileId?: string) { bookingsQuery, dashboardQuery, contractsQuery, + invoicesQuery, allContracts, recentContracts, activeContractsCount, diff --git a/apps/edr-freight-web/portal/src/pages/billing/BillingPage.tsx b/apps/edr-freight-web/portal/src/pages/billing/BillingPage.tsx deleted file mode 100644 index 8b7ac4557..000000000 --- a/apps/edr-freight-web/portal/src/pages/billing/BillingPage.tsx +++ /dev/null @@ -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("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 ( -
-
- -
-
-

{inv.number}

-

Issued {inv.issueDate}

-
-
- ); - }, - }, - { - accessorKey: "customer", - header: "Customer", - }, - { - accessorKey: "bookingReference", - header: "Booking", - }, - { - id: "amount", - header: "Amount", - cell: ({ row }) => { - const inv = row.original; - return ( - - {formatCurrency(inv.amount, inv.currency)} - - ); - }, - }, - { - accessorKey: "dueDate", - header: "Due Date", - }, - { - accessorKey: "status", - header: "Status", - cell: ({ row }) => , - }, - { - id: "actions", - size: 40, - cell: ({ row }) => { - const invoice = row.original; - return ( -
e.stopPropagation()} - > - - - - - - - - Download - - - e.preventDefault()}> - - Edit - - - - - e.preventDefault()} - variant="destructive" - > - - Void - - - - -
- ); - }, - }, - ]; - - return ( -
-
- - - -
-

- Billing -

-

- Manage invoices, payments, and financial records. -

-
- -
-
- - { - setQuery(e.target.value); - setPagination({ - pageIndex: 0, - pageSize: pagination.pageSize, - }); - }} - placeholder="Search invoices..." - className="pl-8!" - /> -
- - - - -
-
- -
- - -
-

Total Revenue (USD)

-

- {formatCurrency(totalRevenue, "USD")} -

-
-
- -
-
-
- - - -
-

Outstanding (USD)

-

- {formatCurrency(outstanding, "USD")} -

-
-
- -
-
-
- - - -
-

Overdue Invoices

-

- {overdueCount} -

-
-
- -
-
-
-
- - -
- {FILTERS.map((f) => { - const isActive = f === filter; - const count = - f === "All" - ? invoices.length - : invoices.filter((inv) => inv.status === f).length; - return ( - - ); - })} -
-
- - - -
- Invoices - - Issued invoices and their payment status. - -
- - -
- - - { }} - pagination={{ - pageIndex: pagination.pageIndex, - pageSize: pagination.pageSize, - pageCount: pageCount, - totalCount: total, - }} - tableOptions={{ - state: { pagination }, - onPaginationChange: setPagination, - }} - containerClassName="border-b shadow-none" - footer={DataTableFooter} - /> - -
-
-
- ); -} - -function StatusBadge({ status }: { status: InvoiceStatus }) { - const styles: Record = { - 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 ( - - {status} - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/billing/DeleteInvoiceDialog.tsx b/apps/edr-freight-web/portal/src/pages/billing/DeleteInvoiceDialog.tsx deleted file mode 100644 index a4e278cb1..000000000 --- a/apps/edr-freight-web/portal/src/pages/billing/DeleteInvoiceDialog.tsx +++ /dev/null @@ -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 ( - - {children} - - - - - Void invoice? - - - - This will void invoice{" "} - - {invoiceNumber} - - . This action cannot be undone. - - - - - - - - - - - - - - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx new file mode 100644 index 000000000..510c7aad7 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx @@ -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 ( + + + {label} + + + {value} + + + ); +} + +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 ( +
+ +
+ ); + } + + if (isError || !invoice) { + return ( + + + + We couldn't load this invoice. It may not exist or you may not have + access to it. + + + ); + } + + 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 ( + + + + + {/* Header */} + + + + {invoice.invoiceNumber} + + + + {payable && ( + + )} + + + {payMutation.isError && ( + } title="Payment could not be started"> + Please try again, or contact support if the problem persists. + + )} + + {/* Summary */} + + + + + + + + + + + + + Total + + + {formatCurrency(Number(invoice.totalAmount), invoice.currency)} + + + + + {/* Line items */} + + + + Line items + + + + + + + Charge + Qty + Unit Rate + Amount + + + + {lines.length === 0 && ( + + +
+ + No line items on this invoice. + +
+
+
+ )} + {lines.map((line) => ( + + + + {titleCase(line.chargeType)} + + {line.description && ( + + {line.description} + + )} + + + + {Number(line.quantity)} + + + + + {formatCurrency(Number(line.unitRate), line.currency)} + + + + + {formatCurrency(Number(line.amount), line.currency)} + + + + ))} +
+
+
+
+
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/billing/InvoicesList.tsx b/apps/edr-freight-web/portal/src/pages/billing/InvoicesList.tsx new file mode 100644 index 000000000..e1d45857f --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/billing/InvoicesList.tsx @@ -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(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 ( + + + {/* Header */} + + + Invoices + + + + {/* Summary strip */} + + + + + + + {/* Search + filters */} + + + } + value={query} + onChange={(e) => { + setQuery(e.currentTarget.value); + resetPage(); + }} + radius="md" + styles={{ input: { height: 42 } }} + style={{ flex: 1, minWidth: 220, maxWidth: 380 }} + /> + { + if (!v) return; + setPageSize(Number(v)); + setPageIndex(0); + }} + radius="md" + size="xs" + comboboxProps={{ withinPortal: true }} + style={{ width: 76 }} + allowDeselect={false} + /> + + {start}–{end} of {total} + + + + + } + disabled={clampedIndex === 0} + onClick={() => goToPage(clampedIndex - 1)} + ariaLabel="Previous page" + /> + {pageNumbers(clampedIndex, pageCount).map((p, i) => + p === "…" ? ( + + … + + ) : ( + goToPage(p)} + /> + ), + )} + } + disabled={clampedIndex >= pageCount - 1} + onClick={() => goToPage(clampedIndex + 1)} + ariaLabel="Next page" + /> + +
+ )} + + + + ); +} + +/** 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 ( + + {page + 1} + + ); +} + +function PagerButton({ + icon, + disabled, + onClick, + ariaLabel, +}: { + icon: React.ReactNode; + disabled: boolean; + onClick: () => void; + ariaLabel: string; +}) { + return ( + + {icon} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/billing/NewInvoicePage.tsx b/apps/edr-freight-web/portal/src/pages/billing/NewInvoicePage.tsx deleted file mode 100644 index 3e78132cd..000000000 --- a/apps/edr-freight-web/portal/src/pages/billing/NewInvoicePage.tsx +++ /dev/null @@ -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 ( - - - {children ?? } - - - - - {title} - {description} - - -
- {/* Invoice Number */} -
- -
- - -
-
- - {/* Status */} -
- - -
- - {/* Customer */} -
- - -
- - {/* Booking */} -
- - -
- - {/* Amount */} -
- -
- - -
-
- - {/* Currency */} -
- - -
- - {/* Issue Date */} -
- -
- - -
-
- - {/* Due Date */} -
- -
- - -
-
- - {/* Notes */} -
- -