import { Document, Page, Text, View, Image, StyleSheet, pdf, } from "@react-pdf/renderer"; import { saveAs } from "file-saver"; import React from "react"; // Fallback download function for better browser compatibility function downloadBlob(blob: Blob, filename: string): void { try { // Validate blob before download if (!blob || blob.size === 0) { throw new Error("Invalid blob for download"); } // First try using file-saver saveAs(blob, filename); } catch (error) { console.warn("file-saver failed, using fallback method:", error); try { // Fallback to manual download const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; link.style.display = "none"; // Add MIME type hint to link if (blob.type) { link.type = blob.type; } document.body.appendChild(link); link.click(); document.body.removeChild(link); console.log("PDF download initiated successfully via fallback method"); // Clean up the object URL setTimeout(() => { URL.revokeObjectURL(url); console.log("Object URL cleaned up"); }, 1000); } catch (fallbackError) { console.error("Both download methods failed:", fallbackError); throw new Error("Unable to download PDF file"); } } } export interface InvoiceData { invoiceNumber: string; invoicePrefix?: string | null; issueDate: Date; dueDate: Date; status: string; totalAmount: number; taxRate: number; currency?: string | null; notes?: string | null; business?: { name: string; nickname?: string | null; 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; } export interface PDFGenerationSettings { pdfTemplate?: "classic" | "minimal"; pdfAccentColor?: string; pdfFooterText?: string; pdfShowLogo?: boolean; pdfShowPageNumbers?: boolean; } const defaultPDFSettings: Required = { pdfTemplate: "classic", pdfAccentColor: "#111827", pdfFooterText: "Professional Invoicing", pdfShowLogo: true, pdfShowPageNumbers: true, }; function resolvePDFSettings(settings?: PDFGenerationSettings) { return { ...defaultPDFSettings, ...settings }; } const styles = StyleSheet.create({ page: { flexDirection: "column", backgroundColor: "#ffffff", fontFamily: "Helvetica", fontSize: 10, paddingTop: 40, paddingBottom: 80, paddingHorizontal: 40, }, // Dense header (first page) denseHeader: { marginBottom: 30, borderBottom: "1px solid #e5e7eb", paddingBottom: 20, }, headerTop: { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 20, }, businessSection: { flexDirection: "column", flex: 1, }, businessName: { fontFamily: "Helvetica-Bold", fontSize: 18, color: "#0f0f0f", marginBottom: 4, }, businessInfo: { fontSize: 10, fontFamily: "Helvetica", color: "#6b7280", lineHeight: 1.4, marginBottom: 3, }, businessAddress: { fontSize: 10, fontFamily: "Helvetica", color: "#6b7280", lineHeight: 1.4, marginTop: 4, }, invoiceSection: { flexDirection: "column", alignItems: "flex-end", }, invoiceTitle: { fontSize: 28, fontFamily: "Helvetica-Bold", color: "#0f0f0f", marginBottom: 8, }, invoiceNumber: { fontSize: 14, fontFamily: "Helvetica-Bold", color: "#374151", marginBottom: 4, }, statusBadge: { paddingHorizontal: 8, paddingVertical: 4, fontSize: 11, fontFamily: "Helvetica-Bold", textAlign: "center", }, statusPaid: { backgroundColor: "#f9fafb", color: "#374151", }, statusUnpaid: { backgroundColor: "#f9fafb", color: "#6b7280", }, // Details section (first page only) detailsSection: { flexDirection: "row", justifyContent: "space-between", marginBottom: 20, }, detailsColumn: { flex: 1, marginRight: 20, }, sectionTitle: { fontSize: 12, fontFamily: "Helvetica-Bold", color: "#0f0f0f", marginBottom: 12, }, clientName: { fontFamily: "Helvetica-Bold", fontSize: 12, color: "#0f0f0f", marginBottom: 2, }, clientInfo: { fontSize: 10, fontFamily: "Helvetica", color: "#6b7280", lineHeight: 1.4, marginBottom: 2, }, clientAddress: { fontSize: 10, fontFamily: "Helvetica", color: "#6b7280", lineHeight: 1.4, marginTop: 4, }, detailRow: { flexDirection: "row", justifyContent: "space-between", marginBottom: 4, }, detailLabel: { fontSize: 11, fontFamily: "Helvetica", color: "#6b7280", flex: 1, }, detailValue: { fontSize: 10, fontFamily: "Helvetica-Bold", color: "#0f0f0f", flex: 1, textAlign: "right", }, // Notes section (first page only) notesSection: { marginTop: 0, marginBottom: 0, padding: 12, backgroundColor: "#f9fafb", }, notesTitle: { fontSize: 11, fontFamily: "Helvetica-Bold", color: "#0f0f0f", marginBottom: 6, }, notesContent: { fontSize: 10, fontFamily: "Helvetica", color: "#374151", lineHeight: 1.4, }, businessContact: { fontSize: 9, fontFamily: "Helvetica", 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: 12, borderBottom: "1px solid #e5e7eb", }, abridgedBusinessName: { fontSize: 12, fontFamily: "Helvetica-Bold", color: "#0f0f0f", }, abridgedInvoiceInfo: { flexDirection: "row", alignItems: "center", gap: 12, }, abridgedInvoiceTitle: { fontSize: 14, fontFamily: "Helvetica-Bold", color: "#0f0f0f", }, abridgedInvoiceNumber: { fontSize: 12, fontFamily: "Helvetica-Bold", color: "#374151", }, // Table styles tableContainer: { marginBottom: 20, }, tableHeader: { flexDirection: "row", backgroundColor: "#f9fafb", borderBottom: "1px solid #e5e7eb", paddingVertical: 8, paddingHorizontal: 4, }, tableHeaderCell: { fontSize: 10, fontFamily: "Helvetica-Bold", color: "#374151", 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: 6, alignItems: "flex-start", minHeight: 24, }, tableRowAlt: { backgroundColor: "#f9fafb", }, tableCell: { fontSize: 10, color: "#0f0f0f", paddingHorizontal: 4, paddingVertical: 2, fontFamily: "Helvetica", }, tableCellDate: { width: "15%", fontFamily: "Courier", alignSelf: "flex-start", }, tableCellDescription: { width: "40%", lineHeight: 1.4, paddingVertical: 4, paddingHorizontal: 2, textAlign: "left", flexWrap: "wrap", fontFamily: "Helvetica", }, tableCellHours: { width: "12%", textAlign: "right", fontFamily: "Courier", alignSelf: "flex-start", }, tableCellRate: { width: "15%", textAlign: "right", fontFamily: "Courier", alignSelf: "flex-start", }, tableCellAmount: { width: "18%", textAlign: "right", fontFamily: "Courier-Bold", alignSelf: "flex-start", }, // Bottom section with notes and totals bottomSection: { marginTop: 20, flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start", }, notesContainer: { width: 240, }, totalsContainer: { width: 240, break: false, }, totalsBox: { width: "100%", padding: 12, backgroundColor: "#f9fafb", break: false, }, totalRow: { flexDirection: "row", justifyContent: "space-between", marginBottom: 4, paddingVertical: 1, }, totalLabel: { fontSize: 11, color: "#6b7280", fontFamily: "Helvetica", }, totalAmount: { fontSize: 11, fontFamily: "Courier-Bold", color: "#0f0f0f", }, finalTotalRow: { flexDirection: "row", justifyContent: "space-between", marginTop: 8, paddingTop: 8, }, finalTotalLabel: { fontSize: 12, fontFamily: "Helvetica-Bold", color: "#0f0f0f", }, finalTotalAmount: { fontSize: 14, fontFamily: "Courier-Bold", color: "#0f0f0f", }, itemCount: { fontSize: 9, fontFamily: "Helvetica", color: "#9ca3af", textAlign: "center", marginTop: 6, }, // Footer footer: { position: "absolute", bottom: 30, left: 40, right: 40, flexDirection: "row", justifyContent: "space-between", alignItems: "center", paddingTop: 12, borderTop: "1px solid #e5e7eb", }, footerLogo: { flexDirection: "row", alignItems: "center", gap: 4, }, pageNumber: { fontSize: 10, fontFamily: "Helvetica", color: "#6b7280", }, }); const minimalStyles = StyleSheet.create({ page: { fontSize: 9, paddingTop: 28, paddingBottom: 48, paddingHorizontal: 32, }, denseHeader: { marginBottom: 16, paddingBottom: 12, }, headerTop: { marginBottom: 10, }, businessName: { fontSize: 14, marginBottom: 2, }, businessInfo: { fontSize: 8, lineHeight: 1.25, marginBottom: 1, }, businessAddress: { fontSize: 8, lineHeight: 1.25, marginTop: 2, }, invoiceTitle: { fontSize: 18, marginBottom: 3, }, invoiceNumber: { fontSize: 10, marginBottom: 2, }, statusBadge: { paddingHorizontal: 0, paddingVertical: 0, backgroundColor: "#ffffff", fontSize: 8, }, headerSeparator: { marginVertical: 4, }, detailsSection: { marginBottom: 10, }, detailsColumn: { marginRight: 14, }, sectionTitle: { fontSize: 8, marginBottom: 5, }, clientName: { fontSize: 9, marginBottom: 1, }, clientInfo: { fontSize: 8, lineHeight: 1.25, marginBottom: 1, }, clientAddress: { fontSize: 8, lineHeight: 1.25, marginTop: 2, }, detailRow: { marginBottom: 2, }, detailLabel: { fontSize: 8, }, detailValue: { fontSize: 8, }, tableContainer: { marginBottom: 10, }, tableHeader: { backgroundColor: "#ffffff", paddingVertical: 4, paddingHorizontal: 0, }, tableHeaderCell: { fontSize: 8, paddingHorizontal: 2, }, tableRow: { paddingVertical: 3, minHeight: 16, }, tableCell: { fontSize: 8, paddingHorizontal: 2, paddingVertical: 1, }, tableCellDescription: { lineHeight: 1.2, paddingVertical: 1, paddingHorizontal: 2, }, bottomSection: { marginTop: 10, }, notesContainer: { width: 260, }, notesSection: { padding: 0, backgroundColor: "#ffffff", }, notesTitle: { fontSize: 8, marginBottom: 4, }, notesContent: { fontSize: 8, lineHeight: 1.25, }, totalsContainer: { width: 190, }, totalsBox: { padding: 0, backgroundColor: "#ffffff", }, totalRow: { marginBottom: 2, paddingVertical: 0, }, totalLabel: { fontSize: 8, }, totalAmount: { fontSize: 8, }, finalTotalRow: { marginTop: 5, paddingTop: 5, }, finalTotalLabel: { fontSize: 9, }, finalTotalAmount: { fontSize: 10, }, itemCount: { fontSize: 7, marginTop: 4, }, footer: { bottom: 20, left: 32, right: 32, paddingTop: 7, }, pageNumber: { fontSize: 8, }, }); // Helper functions const formatCurrency = (amount: number, currency = "USD") => { return new Intl.NumberFormat("en-US", { style: "currency", currency, }).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.toLowerCase()) { case "paid": return [styles.statusBadge, styles.statusPaid]; case "sent": return [styles.statusBadge, styles.statusPaid]; case "overdue": return [ styles.statusBadge, { backgroundColor: "#fef2f2", color: "#dc2626" }, ]; case "draft": return [ styles.statusBadge, { backgroundColor: "#f9fafb", color: "#9ca3af" }, ]; default: return [styles.statusBadge, styles.statusUnpaid]; } }; function getColumnWidths(showRate: boolean) { return showRate ? { date: "15%", description: "40%", hours: "12%", rate: "15%", amount: "18%", } : { date: "15%", description: "48%", hours: "14%", amount: "23%" }; } // Dense header component (first page) const DenseHeader: React.FC<{ invoice: InvoiceData; settings: Required; }> = ({ invoice, settings }) => { const isMinimal = settings.pdfTemplate === "minimal"; return ( {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.invoicePrefix ?? "#"} {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} ); }; // Table header component const TableHeader: React.FC<{ settings: Required; showRate: boolean; }> = ({ settings, showRate }) => { const cols = getColumnWidths(showRate); const isMinimal = settings.pdfTemplate === "minimal"; return ( Date Description Hours {showRate && ( Rate )} Amount ); }; // Footer component const NotesSection: React.FC<{ invoice: InvoiceData; settings: Required; }> = ({ invoice, settings }) => { if (!invoice.notes) return null; const isMinimal = settings.pdfTemplate === "minimal"; return ( NOTES {invoice.notes} ); }; const Footer: React.FC<{ settings: Required }> = ({ settings, }) => { const isMinimal = settings.pdfTemplate === "minimal"; return ( {settings.pdfShowLogo && ( // eslint-disable-next-line jsx-a11y/alt-text -- @react-pdf/renderer Image does not support alt. )} {settings.pdfFooterText} {settings.pdfShowPageNumbers && ( `Page ${pageNumber} of ${totalPages}` } /> )} ); }; // Enhanced totals section component const TotalsSection: React.FC<{ invoice: InvoiceData; items: Array[0]>; settings: Required; }> = ({ invoice, items, settings }) => { const currency = invoice.currency ?? "USD"; const subtotal = items.reduce((sum, item) => sum + (item?.amount ?? 0), 0); const taxAmount = (subtotal * invoice.taxRate) / 100; const total = subtotal + taxAmount; const isMinimal = settings.pdfTemplate === "minimal"; return ( INVOICE SUMMARY Subtotal: {formatCurrency(subtotal, currency)} {invoice.taxRate > 0 && ( Tax ({invoice.taxRate}%): {formatCurrency(taxAmount, currency)} )} TOTAL: {formatCurrency(total, currency)} {items.length} line item{items.length !== 1 ? "s" : ""} ); }; // Main PDF component export const InvoicePDF: React.FC<{ invoice: InvoiceData; settings?: PDFGenerationSettings; }> = ({ invoice, settings: inputSettings }) => { const settings = resolvePDFSettings(inputSettings); const items = invoice.items?.filter(Boolean) ?? []; const currency = invoice.currency ?? "USD"; const showRate = new Set(items.map((item) => item?.rate)).size > 1; const cols = getColumnWidths(showRate); const isMinimal = settings.pdfTemplate === "minimal"; return ( {items.length > 0 && ( {items.map( (item, index) => item && ( {formatDate(item.date)} {item.description} {item.hours} {showRate && ( {formatCurrency(item.rate, currency)} )} {formatCurrency(item.amount, currency)} ), )} )} {invoice.notes && ( )}