import { Document, Page, Text, View, StyleSheet, Font, Image, pdf, } from "@react-pdf/renderer"; import { saveAs } from "file-saver"; import React from "react"; // Register Inter font Font.register({ family: "Inter", src: "/fonts/inter/Inter-Variable.ttf", fontWeight: "normal", }); Font.register({ family: "Inter", src: "/fonts/inter/Inter-Italic-Variable.ttf", fontStyle: "italic", }); // Register Azeret Mono fonts for numbers and tables - multiple weights Font.register({ family: "AzeretMono", src: "/fonts/azeret/AzeretMono-Regular.ttf", fontWeight: "normal", }); Font.register({ family: "AzeretMono", src: "/fonts/azeret/AzeretMono-Regular.ttf", fontWeight: "semibold", }); Font.register({ family: "AzeretMono", src: "/fonts/azeret/AzeretMono-Regular.ttf", fontWeight: "bold", }); Font.register({ family: "AzeretMono", src: "/fonts/azeret/AzeretMono-Italic-Variable.ttf", fontStyle: "italic", }); interface InvoiceData { invoiceNumber: string; issueDate: Date; dueDate: Date; status: string; totalAmount: number; taxRate: number; notes?: string | null; business?: { name: string; email?: string | null; phone?: string | null; addressLine1?: string | null; addressLine2?: string | null; city?: string | null; state?: string | null; postalCode?: string | null; country?: string | null; website?: string | null; taxId?: string | null; } | null; client?: { name: string; email?: string | null; phone?: string | null; addressLine1?: string | null; addressLine2?: string | null; city?: string | null; state?: string | null; postalCode?: string | null; country?: string | null; } | null; items?: Array<{ date: Date; description: string; hours: number; rate: number; amount: number; } | null> | null; } const styles = StyleSheet.create({ page: { flexDirection: "column", backgroundColor: "#ffffff", fontFamily: "Inter", fontSize: 10, paddingTop: 40, paddingBottom: 80, paddingHorizontal: 40, }, // Dense header (first page) denseHeader: { marginBottom: 30, borderBottom: "2px solid #10b981", paddingBottom: 20, }, headerTop: { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 20, }, businessSection: { flexDirection: "column", flex: 1, }, businessName: { fontSize: 24, fontWeight: "bold", color: "#111827", marginBottom: 4, }, businessInfo: { fontSize: 11, color: "#6b7280", marginBottom: 2, }, businessAddress: { fontSize: 11, color: "#6b7280", lineHeight: 1.4, marginTop: 4, }, invoiceSection: { flexDirection: "column", alignItems: "flex-end", }, invoiceTitle: { fontSize: 32, fontWeight: "bold", color: "#10b981", marginBottom: 8, }, invoiceNumber: { fontSize: 15, fontWeight: "semibold", fontFamily: "AzeretMono", color: "#111827", marginBottom: 4, }, statusBadge: { paddingHorizontal: 12, paddingVertical: 4, borderRadius: 4, fontSize: 12, fontWeight: "bold", textAlign: "center", }, statusPaid: { backgroundColor: "#ecfdf5", color: "#065f46", }, statusUnpaid: { backgroundColor: "#fef3c7", color: "#92400e", }, // Details section (first page only) detailsSection: { flexDirection: "row", justifyContent: "space-between", marginBottom: 20, }, detailsColumn: { flex: 1, marginRight: 20, }, sectionTitle: { fontSize: 14, fontWeight: "bold", color: "#111827", marginBottom: 12, }, clientName: { fontSize: 13, fontWeight: "bold", color: "#111827", marginBottom: 4, }, clientInfo: { fontSize: 11, color: "#6b7280", marginBottom: 2, }, clientAddress: { fontSize: 11, color: "#6b7280", lineHeight: 1.4, marginTop: 4, }, detailRow: { flexDirection: "row", justifyContent: "space-between", marginBottom: 4, }, detailLabel: { fontSize: 11, color: "#6b7280", flex: 1, }, detailValue: { fontSize: 10, fontFamily: "AzeretMono", color: "#111827", fontWeight: "semibold", flex: 1, textAlign: "right", }, // Notes section (first page only) notesSection: { marginTop: 20, marginBottom: 20, padding: 15, backgroundColor: "#f9fafb", borderRadius: 4, border: "1px solid #e5e7eb", }, notesTitle: { fontSize: 12, fontWeight: "bold", color: "#111827", marginBottom: 6, }, notesContent: { fontSize: 10, color: "#6b7280", lineHeight: 1.4, }, // Separator styles headerSeparator: { height: 1, backgroundColor: "#e5e7eb", marginVertical: 8, }, // Abridged header (other pages) abridgedHeader: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", marginBottom: 20, paddingBottom: 15, borderBottom: "1px solid #e5e7eb", }, abridgedBusinessName: { fontSize: 18, fontWeight: "bold", color: "#111827", }, abridgedInvoiceInfo: { flexDirection: "row", alignItems: "center", gap: 15, }, abridgedInvoiceTitle: { fontSize: 16, fontWeight: "bold", color: "#10b981", }, abridgedInvoiceNumber: { fontSize: 13, fontWeight: "semibold", fontFamily: "AzeretMono", color: "#111827", }, // Table styles tableContainer: { flex: 1, marginBottom: 20, }, tableHeader: { flexDirection: "row", backgroundColor: "#f3f4f6", borderBottom: "2px solid #10b981", paddingVertical: 8, paddingHorizontal: 4, }, tableHeaderCell: { fontSize: 11, fontWeight: "bold", color: "#111827", paddingHorizontal: 4, }, tableHeaderDate: { width: "15%", }, tableHeaderDescription: { width: "40%", }, tableHeaderHours: { width: "12%", textAlign: "right", }, tableHeaderRate: { width: "15%", textAlign: "right", }, tableHeaderAmount: { width: "18%", textAlign: "right", }, tableRow: { flexDirection: "row", borderBottom: "1px solid #e5e7eb", paddingVertical: 2, paddingHorizontal: 4, alignItems: "flex-start", }, tableRowAlt: { backgroundColor: "#f9fafb", }, tableCell: { fontSize: 10, color: "#111827", paddingHorizontal: 4, paddingVertical: 2, }, tableCellDate: { width: "15%", fontFamily: "AzeretMono", fontWeight: "semibold", alignSelf: "flex-start", }, tableCellDescription: { width: "40%", lineHeight: 1.3, alignSelf: "flex-start", }, tableCellHours: { width: "12%", textAlign: "right", fontFamily: "AzeretMono", fontWeight: "semibold", alignSelf: "flex-start", }, tableCellRate: { width: "15%", textAlign: "right", fontFamily: "AzeretMono", fontWeight: "semibold", alignSelf: "flex-start", }, tableCellAmount: { width: "18%", textAlign: "right", fontFamily: "AzeretMono", fontWeight: "bold", alignSelf: "flex-start", }, // Totals section totalsSection: { marginTop: 20, flexDirection: "row", justifyContent: "flex-end", }, totalsBox: { width: 250, padding: 15, backgroundColor: "#f9fafb", border: "1px solid #e5e7eb", borderRadius: 4, }, totalRow: { flexDirection: "row", justifyContent: "space-between", marginBottom: 6, }, totalLabel: { fontSize: 11, color: "#6b7280", }, totalAmount: { fontSize: 10, fontFamily: "AzeretMono", color: "#111827", fontWeight: "semibold", }, finalTotalRow: { flexDirection: "row", justifyContent: "space-between", marginTop: 8, paddingTop: 8, borderTop: "2px solid #10b981", }, finalTotalLabel: { fontSize: 14, fontWeight: "bold", color: "#1f2937", }, finalTotalAmount: { fontSize: 15, fontFamily: "AzeretMono", fontWeight: "bold", color: "#10b981", }, itemCount: { fontSize: 9, color: "#6b7280", textAlign: "center", marginTop: 6, fontStyle: "italic", }, // Footer footer: { position: "absolute", bottom: 30, left: 40, right: 40, flexDirection: "row", justifyContent: "space-between", alignItems: "center", paddingTop: 15, borderTop: "1px solid #e5e7eb", }, footerLogo: { flexDirection: "row", alignItems: "center", gap: 8, }, pageNumber: { fontSize: 10, color: "#6b7280", }, }); // Helper functions const formatCurrency = (amount: number) => { return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", }).format(amount); }; const formatDate = (date: Date) => { return new Date(date).toLocaleDateString("en-US", { year: "numeric", month: "2-digit", day: "2-digit", }); }; const getStatusLabel = (status: string) => { switch (status) { case "paid": return "PAID"; case "draft": case "sent": case "overdue": default: return "UNPAID"; } }; const getStatusStyle = (status: string) => { switch (status) { case "paid": return [styles.statusBadge, styles.statusPaid]; default: return [styles.statusBadge, styles.statusUnpaid]; } }; // Dynamic pagination calculation based on page height function calculateItemsPerPage( isFirstPage: boolean, hasNotes: boolean, ): number { // Estimate available space in points (1 point = 1/72 inch) const pageHeight = 792; // Letter size height in points const margins = 80; // Top + bottom margins const footerSpace = 60; // Footer space let availableHeight = pageHeight - margins - footerSpace; if (isFirstPage) { // Dense header takes significant space availableHeight -= 200; // Dense header space } else { // Abridged header is smaller availableHeight -= 60; // Abridged header space } if (hasNotes) { // Last page needs space for totals and notes availableHeight -= 120; // Totals + notes space } else { // Regular page just needs totals space availableHeight -= 80; // Totals space only } // Table header takes space availableHeight -= 30; // Table header // Each row is approximately 18 points (includes padding and text) const rowHeight = 18; return Math.max(1, Math.floor(availableHeight / rowHeight)); } // Dynamic pagination function function paginateItems( items: NonNullable, hasNotes = false, ) { const validItems = items.filter(Boolean); const pages: Array = []; if (validItems.length === 0) { return [[]]; } let currentIndex = 0; let pageIndex = 0; while (currentIndex < validItems.length) { const isFirstPage = pageIndex === 0; const remainingItems = validItems.length - currentIndex; // Calculate items per page for this page let itemsPerPage = calculateItemsPerPage(isFirstPage, false); // Check if this would create orphans (< 4 items on next page) if (remainingItems > itemsPerPage && remainingItems - itemsPerPage < 4) { // Distribute items more evenly to avoid orphans itemsPerPage = Math.floor(remainingItems / 2); } // Check if this is the last page and needs space for totals/notes const isLastPage = currentIndex + itemsPerPage >= validItems.length; if (isLastPage && hasNotes) { // Recalculate with space for totals and notes const maxItemsWithNotes = calculateItemsPerPage(false, true); itemsPerPage = Math.min(itemsPerPage, maxItemsWithNotes); } const pageItems = validItems.slice( currentIndex, currentIndex + itemsPerPage, ); pages.push(pageItems); currentIndex += itemsPerPage; pageIndex++; } return pages; } // Dense header component (first page) const DenseHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => ( {invoice.business?.name ?? "Your Business Name"} {invoice.business?.email && ( {invoice.business.email} )} {invoice.business?.phone && ( {invoice.business.phone} )} {(invoice.business?.addressLine1 ?? invoice.business?.city ?? invoice.business?.state) && ( {[ invoice.business?.addressLine1, invoice.business?.addressLine2, invoice.business?.city && invoice.business?.state && invoice.business?.postalCode ? `${invoice.business.city}, ${invoice.business.state} ${invoice.business.postalCode}` : [ invoice.business?.city, invoice.business?.state, invoice.business?.postalCode, ] .filter(Boolean) .join(", "), invoice.business?.country, ] .filter(Boolean) .join("\n")} )} INVOICE #{invoice.invoiceNumber} {getStatusLabel(invoice.status)} BILL TO: {invoice.client?.name ?? "N/A"} {invoice.client?.email && ( {invoice.client.email} )} {invoice.client?.phone && ( {invoice.client.phone} )} {(invoice.client?.addressLine1 ?? invoice.client?.city ?? invoice.client?.state) && ( {[ invoice.client?.addressLine1, invoice.client?.addressLine2, invoice.client?.city, invoice.client?.state, invoice.client?.postalCode, ] .filter(Boolean) .join(", ")} {invoice.client?.country ? "\n" + invoice.client.country : ""} )} INVOICE DETAILS: Issue Date: {formatDate(invoice.issueDate)} Due Date: {formatDate(invoice.dueDate)} Invoice #: {invoice.invoiceNumber} ); // Abridged header component (other pages) const AbridgedHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => ( {invoice.business?.name ?? "Your Business Name"} INVOICE #{invoice.invoiceNumber} ); // Table header component const TableHeader: React.FC = () => ( Date Description Hours Rate Amount ); // Footer component const NotesSection: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => { if (!invoice.notes) return null; return ( Notes: {invoice.notes} ); }; const Footer: React.FC = () => ( `Page ${pageNumber} of ${totalPages}` } /> ); // Totals section component const TotalsSection: React.FC<{ invoice: InvoiceData; items: Array[0]>; }> = ({ invoice, items }) => { const subtotal = items.reduce((sum, item) => sum + (item?.amount ?? 0), 0); const taxAmount = (subtotal * invoice.taxRate) / 100; return ( Subtotal: {formatCurrency(subtotal)} {invoice.taxRate > 0 && ( Tax ({invoice.taxRate}%): {formatCurrency(taxAmount)} )} Total: {formatCurrency(invoice.totalAmount)} {items.length} item{items.length !== 1 ? "s" : ""} ); }; // Main PDF component const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => { const items = invoice.items?.filter(Boolean) ?? []; const paginatedItems = paginateItems(items, Boolean(invoice.notes)); return ( {paginatedItems.map((pageItems, pageIndex) => { const isFirstPage = pageIndex === 0; const isLastPage = pageIndex === paginatedItems.length - 1; const hasItems = pageItems.length > 0; return ( {/* Header */} {isFirstPage ? ( ) : ( )} {/* Table */} {hasItems && ( {pageItems.map( (item, index) => item && ( {formatDate(item.date)} {item.description} {item.hours} {formatCurrency(item.rate)} {formatCurrency(item.amount)} ), )} )} {/* Totals (only on last page) */} {isLastPage && } {/* Notes (only on last page) */} {isLastPage && } {/* Footer */}