diff --git a/apps/api/src/modules/invoices/invoices.service.ts b/apps/api/src/modules/invoices/invoices.service.ts index ea44395..d28752a 100644 --- a/apps/api/src/modules/invoices/invoices.service.ts +++ b/apps/api/src/modules/invoices/invoices.service.ts @@ -54,7 +54,12 @@ function computeTotals( lineItems: Array<{ qty: number; unitPrice: number }>, taxRate?: number, discount?: number, -): { lineItems: LineItem[]; subtotal: number; taxAmount: number; total: number } { +): { + lineItems: LineItem[]; + subtotal: number; + taxAmount: number; + total: number; +} { const enriched: LineItem[] = lineItems.map((item) => ({ description: (item as LineItem).description, qty: item.qty, @@ -235,7 +240,8 @@ export class InvoicesService { async getById(id: string, merchantId: string): Promise { const invoice = await this.prisma.invoice.findUnique({ where: { id } }); if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); - if (invoice.merchantId !== merchantId) throw new ForbiddenException('Access denied'); + if (invoice.merchantId !== merchantId) + throw new ForbiddenException('Access denied'); return invoice; } @@ -375,11 +381,19 @@ export class InvoicesService { }, }); - // Ensure PDF exists before sending - const pdfUrl = existing.pdfUrl ?? (await this.generatePdf(id, merchantId)); + // Generate PDF buffer for email attachment + const templateData = this.buildTemplateData(existing, merchant); + const pdfBuffer = await this.pdf.generateInvoicePdf(templateData); + + // Upload to storage and persist URL (idempotent — skip if already stored) + let pdfUrl = existing.pdfUrl; + if (!pdfUrl) { + const key = `invoices/${merchantId}/${id}/invoice-${id}.pdf`; + pdfUrl = await this.storage.upload(key, pdfBuffer, 'application/pdf'); + await this.prisma.invoice.update({ where: { id }, data: { pdfUrl } }); + } - const amountDue = - toNumber(existing.total) - toNumber(existing.amountPaid); + const amountDue = toNumber(existing.total) - toNumber(existing.amountPaid); // Send invoice email with branding + tracking pixel + Pay Now link await this.notifications.sendInvoice( @@ -390,8 +404,7 @@ export class InvoicesService { amount: amountDue, currency: existing.currency, dueDate: - existing.dueDate ?? - new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + existing.dueDate ?? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), merchantName: merchant.companyName ?? merchant.name, merchantEmail: merchant.email, merchantLogo: merchant.logoUrl ?? undefined, @@ -401,7 +414,7 @@ export class InvoicesService { trackingPixelUrl: `${this.apiUrl}/v1/invoices/${id}/track`, checkoutUrl: `${this.checkoutUrl}/invoice/${id}`, }, - pdfUrl, + pdfBuffer, ); // Update status @@ -517,7 +530,14 @@ export class InvoicesService { status: { in: [InvoiceStatus.SENT, InvoiceStatus.VIEWED] }, dueDate: { lt: new Date() }, }, - select: { id: true, merchantId: true, customerEmail: true, total: true, currency: true, dueDate: true }, + select: { + id: true, + merchantId: true, + customerEmail: true, + total: true, + currency: true, + dueDate: true, + }, }); if (overdueInvoices.length === 0) return 0; @@ -538,7 +558,10 @@ export class InvoicesService { dueDate: invoice.dueDate?.toISOString(), }) .catch((err) => - this.logger.warn(`invoice.overdue webhook failed for ${invoice.id}`, err), + this.logger.warn( + `invoice.overdue webhook failed for ${invoice.id}`, + err, + ), ); } @@ -594,7 +617,9 @@ export class InvoicesService { if (!merchant) throw new NotFoundException('Merchant not found'); - return this.pdf.generateInvoicePdf(this.buildTemplateData(invoice, merchant)); + return this.pdf.generateInvoicePdf( + this.buildTemplateData(invoice, merchant), + ); } // ── Private helpers ──────────────────────────────────────────────────────── @@ -607,10 +632,16 @@ export class InvoicesService { const now = Date.now(); const due = dueDate.getTime(); - const jobs: Array<{ reminderType: ReminderJobData['reminderType']; delay: number }> = [ - { reminderType: 'before_due', delay: due - now - 3 * 24 * 60 * 60 * 1000 }, - { reminderType: 'on_due', delay: due - now }, - { reminderType: 'after_due', delay: due - now + 3 * 24 * 60 * 60 * 1000 }, + const jobs: Array<{ + reminderType: ReminderJobData['reminderType']; + delay: number; + }> = [ + { + reminderType: 'before_due', + delay: due - now - 3 * 24 * 60 * 60 * 1000, + }, + { reminderType: 'on_due', delay: due - now }, + { reminderType: 'after_due', delay: due - now + 3 * 24 * 60 * 60 * 1000 }, ]; for (const job of jobs) { @@ -622,7 +653,7 @@ export class InvoicesService { delay: job.delay, attempts: 3, backoff: { type: 'exponential', delay: 5000 }, - jobId: `${invoiceId}:${job.reminderType}`, // idempotent — won't duplicate on re-send + jobId: `${invoiceId}_${job.reminderType}`, // idempotent — won't duplicate on re-send }, ); } diff --git a/apps/api/src/modules/notifications/notifications.processor.ts b/apps/api/src/modules/notifications/notifications.processor.ts index 3869c22..cc56d16 100644 --- a/apps/api/src/modules/notifications/notifications.processor.ts +++ b/apps/api/src/modules/notifications/notifications.processor.ts @@ -49,12 +49,14 @@ export class NotificationsProcessor extends WorkerHost { if (response.error) { this.logger.error( - `Failed to send email to ${toString}: ${response.error.message}`, + `Resend rejected email to ${toString}: ${JSON.stringify(response.error)}`, ); throw new Error(response.error.message); } - this.logger.log(`Successfully sent email to ${toString}`); + this.logger.log( + `Email accepted by Resend — id: ${response.data?.id}, to: ${toString}, from: ${this.fromEmail}, subject: "${subject}"`, + ); } catch (error: unknown) { const errMsg = error instanceof Error ? error.message : 'Unknown error'; diff --git a/apps/api/src/modules/notifications/notifications.service.ts b/apps/api/src/modules/notifications/notifications.service.ts index 92a0d80..6578565 100644 --- a/apps/api/src/modules/notifications/notifications.service.ts +++ b/apps/api/src/modules/notifications/notifications.service.ts @@ -91,16 +91,16 @@ export class NotificationsService { async sendInvoice( customerEmail: string, invoice: Invoice, - pdfUrl: string, + pdfBuffer: Buffer, ): Promise { await this.dispatch({ to: customerEmail, - subject: `Invoice ${invoice.id} is available`, + subject: `Invoice ${invoice.reference ?? invoice.id} is available`, html: templates.invoiceTemplate(invoice, this.appUrl), attachments: [ { - filename: `invoice-${invoice.id}.pdf`, - path: pdfUrl, + filename: `invoice-${invoice.reference ?? invoice.id}.pdf`, + content: pdfBuffer.toString('base64'), }, ], }); diff --git a/apps/dashboard/src/app/(dashboard)/invoices/page.tsx b/apps/dashboard/src/app/(dashboard)/invoices/page.tsx index c517db0..c42adfc 100644 --- a/apps/dashboard/src/app/(dashboard)/invoices/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/invoices/page.tsx @@ -1,22 +1,26 @@ "use client"; -import { useState } from "react"; -import { Plus, Search } from "lucide-react"; -import { Button, Input, useToast } from "@useroutr/ui"; +import { useState, useCallback } from "react"; +import { Plus, Search, Download, X, ChevronDown } from "lucide-react"; +import { Button, useToast } from "@useroutr/ui"; import { InvoiceDrawer } from "@/components/invoices/InvoiceDrawer"; import { InvoicesTable } from "@/components/invoices/InvoicesTable"; +import { InvoiceDetailSheet } from "@/components/invoices/InvoiceDetailSheet"; import { useInvoices, useCreateInvoice, useUpdateInvoice, useDeleteInvoice, useSendInvoice, + useCancelInvoice, useInvoicePdfUrl, type Invoice, type InvoiceStatus, type CreateInvoiceInput, } from "@/hooks/useInvoices"; +// ── Constants ────────────────────────────────────────────────────────────────── + const STATUS_FILTERS: Array<{ label: string; value: InvoiceStatus | "ALL" }> = [ { label: "All", value: "ALL" }, { label: "Draft", value: "DRAFT" }, @@ -25,22 +29,89 @@ const STATUS_FILTERS: Array<{ label: string; value: InvoiceStatus | "ALL" }> = [ { label: "Partial", value: "PARTIALLY_PAID" }, { label: "Paid", value: "PAID" }, { label: "Overdue", value: "OVERDUE" }, + { label: "Cancelled", value: "CANCELLED" }, +]; + +const CURRENCIES = [ + "USD", "EUR", "GBP", "NGN", "KES", "GHS", "ZAR", + "CAD", "AUD", "JPY", "CNY", "INR", "BRL", "MXN", + "AED", "SGD", "CHF", ]; +// ── CSV export helper ────────────────────────────────────────────────────────── + +function exportToCsv(invoices: Invoice[]) { + const headers = [ + "Invoice #", + "Customer Name", + "Customer Email", + "Currency", + "Subtotal", + "Tax", + "Discount", + "Total", + "Amount Paid", + "Status", + "Due Date", + "Created At", + ]; + + const rows = invoices.map((inv) => [ + inv.invoiceNumber ?? inv.id, + inv.customerName ?? "", + inv.customerEmail, + inv.currency, + inv.subtotal, + inv.taxAmount ?? "0", + inv.discount ?? "0", + inv.total, + inv.amountPaid, + inv.status, + inv.dueDate ? new Date(inv.dueDate).toLocaleDateString("en-US") : "", + new Date(inv.createdAt).toLocaleDateString("en-US"), + ]); + + const csv = [headers, ...rows] + .map((row) => + row + .map((cell) => { + const str = String(cell ?? ""); + return str.includes(",") || str.includes('"') || str.includes("\n") + ? `"${str.replace(/"/g, '""')}"` + : str; + }) + .join(","), + ) + .join("\n"); + + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `invoices-${new Date().toISOString().split("T")[0]}.csv`; + link.click(); + URL.revokeObjectURL(url); +} + +// ── Page ────────────────────────────────────────────────────────────────────── + export default function InvoicesPage() { const { toast } = useToast(); // ── Filter state ────────────────────────────────────────────────────────── const [statusFilter, setStatusFilter] = useState("ALL"); const [search, setSearch] = useState(""); + const [currency, setCurrency] = useState(""); + const [dateFrom, setDateFrom] = useState(""); + const [dateTo, setDateTo] = useState(""); const [page, setPage] = useState(1); + const [sortBy, setSortBy] = useState("createdAt"); + const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); - // ── Drawer state ────────────────────────────────────────────────────────── + // ── Drawer / sheet state ────────────────────────────────────────────────── const [drawerOpen, setDrawerOpen] = useState(false); const [editingInvoice, setEditingInvoice] = useState(); - - // ── Send-confirm state ──────────────────────────────────────────────────── - const [sendTarget, setSendTarget] = useState(); + const [detailInvoice, setDetailInvoice] = useState(null); // ── Data ────────────────────────────────────────────────────────────────── const { data, isLoading } = useInvoices({ @@ -48,18 +119,48 @@ export default function InvoicesPage() { limit: 20, status: statusFilter === "ALL" ? undefined : statusFilter, search: search || undefined, + currency: currency || undefined, + from: dateFrom || undefined, + to: dateTo || undefined, + sortBy, + sortOrder, }); const createInvoice = useCreateInvoice(); const updateInvoice = useUpdateInvoice(); const deleteInvoice = useDeleteInvoice(); const sendInvoice = useSendInvoice(); + const cancelInvoice = useCancelInvoice(); const getPdfUrl = useInvoicePdfUrl(); const invoices = data?.data ?? []; const meta = data?.meta; - // ── Handlers ────────────────────────────────────────────────────────────── + // ── Sort handler ────────────────────────────────────────────────────────── + const handleSort = useCallback( + (col: string) => { + if (sortBy === col) { + setSortOrder((prev) => (prev === "asc" ? "desc" : "asc")); + } else { + setSortBy(col); + setSortOrder("asc"); + } + setPage(1); + }, + [sortBy], + ); + + // ── Filter reset ────────────────────────────────────────────────────────── + const hasActiveFilters = currency || dateFrom || dateTo; + + const clearFilters = () => { + setCurrency(""); + setDateFrom(""); + setDateTo(""); + setPage(1); + }; + + // ── Drawer handlers ─────────────────────────────────────────────────────── const openCreate = () => { setEditingInvoice(undefined); @@ -67,6 +168,7 @@ export default function InvoicesPage() { }; const openEdit = (invoice: Invoice) => { + setDetailInvoice(null); setEditingInvoice(invoice); setDrawerOpen(true); }; @@ -90,13 +192,11 @@ export default function InvoicesPage() { const handleSend = async (input: CreateInvoiceInput) => { try { let invoice = editingInvoice; - if (invoice) { invoice = await updateInvoice.mutateAsync({ id: invoice.id, body: input }); } else { invoice = await createInvoice.mutateAsync(input); } - await sendInvoice.mutateAsync({ id: invoice.id }); toast(`Invoice sent to ${invoice.customerEmail}.`, "success"); setDrawerOpen(false); @@ -110,23 +210,49 @@ export default function InvoicesPage() { try { await sendInvoice.mutateAsync({ id: invoice.id }); toast(`Invoice sent to ${invoice.customerEmail}.`, "success"); + // Update the detail sheet if it's showing this invoice + setDetailInvoice((prev) => + prev?.id === invoice.id ? { ...prev, status: "SENT" } : prev, + ); } catch (err) { toast(err instanceof Error ? err.message : "Could not send invoice", "error"); } }; const handleDelete = async (invoice: Invoice) => { - if (!confirm(`Delete invoice ${invoice.invoiceNumber ?? invoice.id}? This cannot be undone.`)) { + if ( + !confirm( + `Delete invoice ${invoice.invoiceNumber ?? invoice.id}? This cannot be undone.`, + ) + ) return; - } try { await deleteInvoice.mutateAsync(invoice.id); toast("Invoice deleted.", "success"); + if (detailInvoice?.id === invoice.id) setDetailInvoice(null); } catch (err) { toast(err instanceof Error ? err.message : "Could not delete", "error"); } }; + const handleCancel = async (invoice: Invoice) => { + if ( + !confirm( + `Cancel invoice ${invoice.invoiceNumber ?? invoice.id}? The client will no longer be able to pay it.`, + ) + ) + return; + try { + await cancelInvoice.mutateAsync(invoice.id); + toast("Invoice cancelled.", "success"); + setDetailInvoice((prev) => + prev?.id === invoice.id ? { ...prev, status: "CANCELLED" } : prev, + ); + } catch (err) { + toast(err instanceof Error ? err.message : "Could not cancel invoice", "error"); + } + }; + const handleDownloadPdf = async (invoice: Invoice) => { try { const { url } = await getPdfUrl.mutateAsync(invoice.id); @@ -136,33 +262,54 @@ export default function InvoicesPage() { } }; + const handleCopyLink = (invoice: Invoice) => { + const checkoutBase = + process.env.NEXT_PUBLIC_CHECKOUT_URL ?? window.location.origin; + const link = `${checkoutBase}/invoice/${invoice.id}`; + navigator.clipboard + .writeText(link) + .then(() => toast("Invoice link copied to clipboard.", "success")) + .catch(() => toast("Could not copy link.", "error")); + }; + + const handleExportCsv = () => { + if (invoices.length === 0) { + toast("No invoices to export.", "error"); + return; + } + exportToCsv(invoices); + toast(`Exported ${invoices.length} invoice${invoices.length !== 1 ? "s" : ""} to CSV.`, "success"); + }; + const isMutating = - createInvoice.isPending || - updateInvoice.isPending || - sendInvoice.isPending; + createInvoice.isPending || updateInvoice.isPending || sendInvoice.isPending; return (
{/* ── Header ── */}
-

- Invoices -

+

Invoices

{meta && (

{meta.total} invoice{meta.total !== 1 ? "s" : ""}

)}
- +
+ + +
{/* ── Filters ── */} -
+
{/* Status tabs */}
{STATUS_FILTERS.map((f) => ( @@ -183,19 +330,77 @@ export default function InvoicesPage() { ))}
- {/* Search */} -
- - { - setSearch(e.target.value); - setPage(1); - }} - className="h-9 w-full sm:w-64 rounded-md border border-input bg-transparent pl-8 pr-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring" - /> + {/* Search + secondary filters row */} +
+ {/* Search */} +
+ + { + setSearch(e.target.value); + setPage(1); + }} + className="h-9 w-full rounded-md border border-input bg-transparent pl-8 pr-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+ + {/* Currency filter */} +
+ + +
+ + {/* Date range */} +
+ { + setDateFrom(e.target.value); + setPage(1); + }} + className="h-9 rounded-md border border-input bg-transparent px-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring text-muted-foreground" + aria-label="From date" + /> + to + { + setDateTo(e.target.value); + setPage(1); + }} + className="h-9 rounded-md border border-input bg-transparent px-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring text-muted-foreground" + aria-label="To date" + /> +
+ + {/* Clear filters */} + {hasActiveFilters && ( + + )}
@@ -203,10 +408,16 @@ export default function InvoicesPage() { @@ -214,7 +425,7 @@ export default function InvoicesPage() { {meta && meta.totalPages > 1 && (
- Page {meta.page} of {meta.totalPages} + Page {meta.page} of {meta.totalPages} · {meta.total} total
); } diff --git a/apps/dashboard/src/components/invoices/InvoiceDetailSheet.tsx b/apps/dashboard/src/components/invoices/InvoiceDetailSheet.tsx new file mode 100644 index 0000000..b961382 --- /dev/null +++ b/apps/dashboard/src/components/invoices/InvoiceDetailSheet.tsx @@ -0,0 +1,363 @@ +"use client"; + +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, +} from "@/components/ui/sheet"; +import { Separator } from "@useroutr/ui"; +import { + Send, + FileDown, + Link2, + XCircle, + Building2, + User, + Mail, + Phone, + MapPin, + Calendar, + Hash, +} from "lucide-react"; +import { Button } from "@useroutr/ui"; +import type { Invoice, InvoiceStatus } from "@/hooks/useInvoices"; + +// ── Status badge ─────────────────────────────────────────────────────────────── + +const STATUS_CONFIG: Record = { + DRAFT: { label: "Draft", className: "bg-muted text-muted-foreground" }, + SENT: { label: "Sent", className: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300" }, + VIEWED: { label: "Viewed", className: "bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300" }, + PARTIALLY_PAID: { label: "Partially Paid", className: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300" }, + PAID: { label: "Paid", className: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300" }, + OVERDUE: { label: "Overdue", className: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300" }, + CANCELLED: { label: "Cancelled", className: "bg-muted text-muted-foreground line-through" }, +}; + +function StatusBadge({ status }: { status: InvoiceStatus }) { + const { label, className } = STATUS_CONFIG[status] ?? STATUS_CONFIG.DRAFT; + return ( + + {label} + + ); +} + +// ── Props ────────────────────────────────────────────────────────────────────── + +interface InvoiceDetailSheetProps { + invoice: Invoice | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onSend: (invoice: Invoice) => void; + onDownloadPdf: (invoice: Invoice) => void; + onCopyLink: (invoice: Invoice) => void; + onCancel: (invoice: Invoice) => void; + onEdit: (invoice: Invoice) => void; + isSendPending?: boolean; +} + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function fmtCurrency(amount: string | number, currency: string) { + const num = typeof amount === "string" ? parseFloat(amount) : amount; + if (isNaN(num)) return `0.00 ${currency}`; + return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(num); +} + +function fmtDate(iso?: string | null) { + if (!iso) return "—"; + return new Date(iso).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); +} + +// ── Component ────────────────────────────────────────────────────────────────── + +export function InvoiceDetailSheet({ + invoice, + open, + onOpenChange, + onSend, + onDownloadPdf, + onCopyLink, + onCancel, + onEdit, + isSendPending, +}: InvoiceDetailSheetProps) { + if (!invoice) return null; + + const subtotal = parseFloat(invoice.subtotal ?? "0"); + const taxAmount = parseFloat(invoice.taxAmount ?? "0"); + const discount = parseFloat(invoice.discount ?? "0"); + const total = parseFloat(invoice.total ?? "0"); + const amountPaid = parseFloat(invoice.amountPaid ?? "0"); + const amountDue = total - amountPaid; + + const isDraft = invoice.status === "DRAFT"; + const isCancellable = !["PAID", "CANCELLED"].includes(invoice.status); + const address = invoice.customerAddress; + + return ( + + + {/* ── Header ── */} + +
+
+ + {invoice.invoiceNumber + ? `Invoice ${invoice.invoiceNumber}` + : `Invoice #${invoice.id.slice(0, 8).toUpperCase()}`} + + Invoice details +
+ + + Created {fmtDate(invoice.createdAt)} + +
+
+
+
+ + {/* ── Scrollable body ── */} +
+
+ + {/* Customer info */} +
+

+ Customer +

+
+ {invoice.customerName && ( +
+ + {invoice.customerName} +
+ )} + + {invoice.customerPhone && ( +
+ + {invoice.customerPhone} +
+ )} + {address && ( +
+ + + {address.line1} + {address.line2 ? `, ${address.line2}` : ""} + {address.city ? `, ${address.city}` : ""} + {address.state ? `, ${address.state}` : ""} + {address.country ? `, ${address.country}` : ""} + {address.zip ? ` ${address.zip}` : ""} + +
+ )} +
+
+ + {/* Invoice meta */} +
+ {invoice.invoiceNumber && ( +
+

+ Invoice # +

+

{invoice.invoiceNumber}

+
+ )} +
+

+ Currency +

+

{invoice.currency}

+
+ {invoice.dueDate && ( +
+

+ Due Date +

+

+ {fmtDate(invoice.dueDate)} +

+
+ )} + {invoice.paidAt && ( +
+

Paid On

+

{fmtDate(invoice.paidAt)}

+
+ )} +
+ + + + {/* Line items */} +
+

+ Line Items +

+
+ {/* Header */} +
+ Description + Qty + Price + Total +
+ {/* Rows */} + {invoice.lineItems.map((item, i) => ( +
+ {item.description} + {item.qty} + + {fmtCurrency(item.unitPrice, invoice.currency)} + + + {fmtCurrency(item.amount, invoice.currency)} + +
+ ))} +
+
+ + {/* Totals */} +
+
+ Subtotal + {fmtCurrency(subtotal, invoice.currency)} +
+ {taxAmount > 0 && ( +
+ Tax + {fmtCurrency(taxAmount, invoice.currency)} +
+ )} + {discount > 0 && ( +
+ Discount + + −{fmtCurrency(discount, invoice.currency)} + +
+ )} + +
+ Total + {fmtCurrency(total, invoice.currency)} +
+ {amountPaid > 0 && ( + <> +
+ Amount Paid + {fmtCurrency(amountPaid, invoice.currency)} +
+
+ Balance Due + {fmtCurrency(amountDue, invoice.currency)} +
+ + )} +
+ + {/* Notes */} + {invoice.notes && ( +
+

+ Notes +

+

+ {invoice.notes} +

+
+ )} +
+
+ + {/* ── Footer actions ── */} +
+
+ {isDraft && ( + + )} + {isDraft && ( + + )} +
+
+ + +
+ {isCancellable && ( + + )} +
+
+
+ ); +} diff --git a/apps/dashboard/src/components/invoices/InvoicesTable.tsx b/apps/dashboard/src/components/invoices/InvoicesTable.tsx index 6007b5e..f7d8dc4 100644 --- a/apps/dashboard/src/components/invoices/InvoicesTable.tsx +++ b/apps/dashboard/src/components/invoices/InvoicesTable.tsx @@ -1,7 +1,20 @@ "use client"; import { useState } from "react"; -import { MoreHorizontal, Send, Eye, Pencil, Trash2, FileDown, FileText } from "lucide-react"; +import { + MoreHorizontal, + Send, + Eye, + Pencil, + Trash2, + FileDown, + FileText, + Link2, + XCircle, + ChevronUp, + ChevronDown, + ChevronsUpDown, +} from "lucide-react"; import { Button } from "@useroutr/ui"; import { DropdownMenu, @@ -14,51 +27,34 @@ import type { Invoice, InvoiceStatus } from "@/hooks/useInvoices"; // ── Status badge ─────────────────────────────────────────────────────────────── -const STATUS_CONFIG: Record< - InvoiceStatus, - { label: string; className: string } -> = { - DRAFT: { - label: "Draft", - className: "bg-muted text-muted-foreground", - }, - SENT: { - label: "Sent", - className: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300", - }, - VIEWED: { - label: "Viewed", - className: "bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300", - }, - PARTIALLY_PAID: { - label: "Partial", - className: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300", - }, - PAID: { - label: "Paid", - className: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300", - }, - OVERDUE: { - label: "Overdue", - className: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300", - }, - CANCELLED: { - label: "Cancelled", - className: "bg-muted text-muted-foreground line-through", - }, +const STATUS_CONFIG: Record = { + DRAFT: { label: "Draft", className: "bg-muted text-muted-foreground" }, + SENT: { label: "Sent", className: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300" }, + VIEWED: { label: "Viewed", className: "bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300" }, + PARTIALLY_PAID: { label: "Partial", className: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300" }, + PAID: { label: "Paid", className: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300" }, + OVERDUE: { label: "Overdue", className: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300" }, + CANCELLED: { label: "Cancelled", className: "bg-muted text-muted-foreground line-through" }, }; function StatusBadge({ status }: { status: InvoiceStatus }) { const { label, className } = STATUS_CONFIG[status] ?? STATUS_CONFIG.DRAFT; return ( - + {label} ); } +// ── Sort indicator ───────────────────────────────────────────────────────────── + +function SortIcon({ col, sortBy, sortOrder }: { col: string; sortBy?: string; sortOrder?: "asc" | "desc" }) { + if (sortBy !== col) return ; + return sortOrder === "asc" + ? + : ; +} + // ── Skeleton row ─────────────────────────────────────────────────────────────── function SkeletonRow() { @@ -81,12 +77,9 @@ function EmptyState({ onCreate }: { onCreate: () => void }) {
-

- No invoices yet -

+

No invoices yet

- Create your first invoice to start billing clients and tracking - payments. + Create your first invoice to start billing clients and tracking payments.

+ ); +} + // ── Main component ──────────────────────────────────────────────────────────── export function InvoicesTable({ invoices, isLoading, + sortBy, + sortOrder, + onSort, + onRowClick, onEdit, onSend, onDelete, onDownloadPdf, + onCopyLink, + onCancel, onCreate, }: InvoicesTableProps) { const [hoveredId, setHoveredId] = useState(null); @@ -125,10 +158,7 @@ export function InvoicesTable({ const num = parseFloat(amount); return isNaN(num) ? `0.00 ${currency}` - : new Intl.NumberFormat("en-US", { - style: "currency", - currency, - }).format(num); + : new Intl.NumberFormat("en-US", { style: "currency", currency }).format(num); }; const fmtDate = (iso?: string) => { @@ -141,24 +171,23 @@ export function InvoicesTable({ }; const isDraft = (inv: Invoice) => inv.status === "DRAFT"; - const isSendable = (inv: Invoice) => inv.status === "DRAFT"; - const isDeletable = (inv: Invoice) => inv.status === "DRAFT"; + const isCancellable = (inv: Invoice) => !["PAID", "CANCELLED"].includes(inv.status); return (
{/* Table header */}
-
- {["Invoice #", "Customer", "Amount", "Status", "Due Date", ""].map( - (h) => ( - - {h} - - ), - )} +
+ + Invoice # + + + Customer + + + + +
@@ -170,22 +199,28 @@ export function InvoicesTable({ ) : ( invoices.map((inv) => { - const amountDue = - parseFloat(inv.total) - parseFloat(inv.amountPaid ?? "0"); + const amountDue = parseFloat(inv.total) - parseFloat(inv.amountPaid ?? "0"); return (
setHoveredId(inv.id)} onMouseLeave={() => setHoveredId(null)} + onClick={(e) => { + // Don't trigger when clicking the actions dropdown + const target = e.target as HTMLElement; + if (target.closest("[data-radix-popper-content-wrapper]")) return; + if (target.closest("[data-invoice-actions]")) return; + onRowClick(inv); + }} > {/* Invoice # */}
- {inv.invoiceNumber ?? `#${inv.id.slice(0, 8)}`} + {inv.invoiceNumber ?? `#${inv.id.slice(0, 8).toUpperCase()}`} {fmtDate(inv.createdAt)} @@ -209,12 +244,11 @@ export function InvoicesTable({ {fmtCurrency(inv.total, inv.currency)} - {parseFloat(inv.amountPaid ?? "0") > 0 && - inv.status !== "PAID" && ( - - +{fmtCurrency(inv.amountPaid, inv.currency)} paid - - )} + {parseFloat(inv.amountPaid ?? "0") > 0 && inv.status !== "PAID" && ( + + +{fmtCurrency(inv.amountPaid, inv.currency)} paid + + )} {inv.status === "PARTIALLY_PAID" && ( {fmtCurrency(String(amountDue), inv.currency)} due @@ -228,66 +262,80 @@ export function InvoicesTable({ {/* Due date */} {fmtDate(inv.dueDate)} {/* Actions */} - - - - - - {isDraft(inv) && ( - onEdit(inv)}> - - Edit draft - - )} - onDownloadPdf(inv)}> - - Download PDF - - {isSendable(inv) && ( - <> - - onSend(inv)}> - - Send to client +
e.stopPropagation()}> + + + + + + {isDraft(inv) && ( + onEdit(inv)}> + + Edit draft - - )} - {isDeletable(inv) && ( - <> - - onDelete(inv)} - className="text-destructive focus:text-destructive" - > - - Delete + )} + {!isDraft(inv) && ( + onRowClick(inv)}> + + View invoice - - )} - {!isDraft(inv) && ( - onDownloadPdf(inv)} - > - - View invoice + )} + onDownloadPdf(inv)}> + + Download PDF - )} - - + onCopyLink(inv)}> + + Copy link + + {isDraft(inv) && ( + <> + + onSend(inv)}> + + Send to client + + + )} + {isCancellable(inv) && ( + <> + + onCancel(inv)} + className="text-destructive focus:text-destructive" + > + + Cancel invoice + + + )} + {isDraft(inv) && ( + <> + + onDelete(inv)} + className="text-destructive focus:text-destructive" + > + + Delete + + + )} + + +
); })