Files
beenvoice-web/src/lib/pdf-export.tsx
T
soconnor 0e46fdafb2 feat: add administration page and account role management
- Implemented `AdministrationContent` component for managing account roles.
- Created `AdministrationPage` to serve as the main entry point for administration tasks.
- Added PDF preview functionality with `PdfPreviewFrame` component for invoice generation.
- Introduced `InputColor` component for advanced color selection with various formats.
- Established color conversion utilities in `color-converter.ts` for handling color formats.
- Defined appearance-related schemas and types in `appearance.ts` for consistent theme management.
2026-04-30 10:50:50 -04:00

1504 lines
34 KiB
TypeScript

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<PDFGenerationSettings> = {
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<PDFGenerationSettings>;
}> = ({ invoice, settings }) => {
const isMinimal = settings.pdfTemplate === "minimal";
return (
<View
style={[styles.denseHeader, isMinimal ? minimalStyles.denseHeader : {}]}
>
<View
style={[styles.headerTop, isMinimal ? minimalStyles.headerTop : {}]}
>
<View style={styles.businessSection}>
<Text
style={[
styles.businessName,
isMinimal ? minimalStyles.businessName : {},
{ color: settings.pdfAccentColor },
]}
>
{invoice.business?.name ?? "Your Business Name"}
</Text>
{invoice.business?.email && (
<Text
style={[
styles.businessInfo,
isMinimal ? minimalStyles.businessInfo : {},
]}
>
{invoice.business.email}
</Text>
)}
{invoice.business?.phone && (
<Text
style={[
styles.businessInfo,
isMinimal ? minimalStyles.businessInfo : {},
]}
>
{invoice.business.phone}
</Text>
)}
{(invoice.business?.addressLine1 ??
invoice.business?.city ??
invoice.business?.state) && (
<Text
style={[
styles.businessAddress,
isMinimal ? minimalStyles.businessAddress : {},
]}
>
{[
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")}
</Text>
)}
</View>
<View style={styles.invoiceSection}>
<Text
style={[
styles.invoiceTitle,
isMinimal ? minimalStyles.invoiceTitle : {},
{ color: settings.pdfAccentColor },
]}
>
INVOICE
</Text>
<Text
style={[
styles.invoiceNumber,
isMinimal ? minimalStyles.invoiceNumber : {},
]}
>
{invoice.invoicePrefix ?? "#"}
{invoice.invoiceNumber}
</Text>
<View
style={[
...getStatusStyle(invoice.status),
isMinimal ? minimalStyles.statusBadge : {},
]}
>
<Text>{getStatusLabel(invoice.status)}</Text>
</View>
</View>
</View>
<View
style={[
styles.headerSeparator,
isMinimal ? minimalStyles.headerSeparator : {},
]}
/>
<View
style={[
styles.detailsSection,
isMinimal ? minimalStyles.detailsSection : {},
]}
>
<View
style={[
styles.detailsColumn,
isMinimal ? minimalStyles.detailsColumn : {},
]}
>
<Text
style={[
styles.sectionTitle,
isMinimal ? minimalStyles.sectionTitle : {},
]}
>
BILL TO:
</Text>
<Text
style={[
styles.clientName,
isMinimal ? minimalStyles.clientName : {},
]}
>
{invoice.client?.name ?? "N/A"}
</Text>
{invoice.client?.email && (
<Text
style={[
styles.clientInfo,
isMinimal ? minimalStyles.clientInfo : {},
]}
>
{invoice.client.email}
</Text>
)}
{invoice.client?.phone && (
<Text
style={[
styles.clientInfo,
isMinimal ? minimalStyles.clientInfo : {},
]}
>
{invoice.client.phone}
</Text>
)}
{(invoice.client?.addressLine1 ??
invoice.client?.city ??
invoice.client?.state) && (
<Text
style={[
styles.clientAddress,
isMinimal ? minimalStyles.clientAddress : {},
]}
>
{[
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 : ""}
</Text>
)}
</View>
<View
style={[
styles.detailsColumn,
isMinimal ? minimalStyles.detailsColumn : {},
]}
>
<Text
style={[
styles.sectionTitle,
isMinimal ? minimalStyles.sectionTitle : {},
]}
>
INVOICE DETAILS:
</Text>
<View
style={[styles.detailRow, isMinimal ? minimalStyles.detailRow : {}]}
>
<Text
style={[
styles.detailLabel,
isMinimal ? minimalStyles.detailLabel : {},
]}
>
Issue Date:
</Text>
<Text
style={[
styles.detailValue,
isMinimal ? minimalStyles.detailValue : {},
]}
>
{formatDate(invoice.issueDate)}
</Text>
</View>
<View
style={[styles.detailRow, isMinimal ? minimalStyles.detailRow : {}]}
>
<Text
style={[
styles.detailLabel,
isMinimal ? minimalStyles.detailLabel : {},
]}
>
Due Date:
</Text>
<Text
style={[
styles.detailValue,
isMinimal ? minimalStyles.detailValue : {},
]}
>
{formatDate(invoice.dueDate)}
</Text>
</View>
<View
style={[styles.detailRow, isMinimal ? minimalStyles.detailRow : {}]}
>
<Text
style={[
styles.detailLabel,
isMinimal ? minimalStyles.detailLabel : {},
]}
>
Invoice #:
</Text>
<Text
style={[
styles.detailValue,
isMinimal ? minimalStyles.detailValue : {},
]}
>
{invoice.invoiceNumber}
</Text>
</View>
</View>
</View>
</View>
);
};
// Table header component
const TableHeader: React.FC<{
settings: Required<PDFGenerationSettings>;
showRate: boolean;
}> = ({ settings, showRate }) => {
const cols = getColumnWidths(showRate);
const isMinimal = settings.pdfTemplate === "minimal";
return (
<View
style={[styles.tableHeader, isMinimal ? minimalStyles.tableHeader : {}]}
>
<Text
style={[
styles.tableHeaderCell,
isMinimal ? minimalStyles.tableHeaderCell : {},
{ width: cols.date },
]}
>
Date
</Text>
<Text
style={[
styles.tableHeaderCell,
isMinimal ? minimalStyles.tableHeaderCell : {},
{ width: cols.description },
]}
>
Description
</Text>
<Text
style={[
styles.tableHeaderCell,
isMinimal ? minimalStyles.tableHeaderCell : {},
styles.tableHeaderHours,
{ width: cols.hours },
]}
>
Hours
</Text>
{showRate && (
<Text
style={[
styles.tableHeaderCell,
isMinimal ? minimalStyles.tableHeaderCell : {},
styles.tableHeaderRate,
{ width: cols.rate },
]}
>
Rate
</Text>
)}
<Text
style={[
styles.tableHeaderCell,
isMinimal ? minimalStyles.tableHeaderCell : {},
styles.tableHeaderAmount,
{ width: cols.amount },
]}
>
Amount
</Text>
</View>
);
};
// Footer component
const NotesSection: React.FC<{
invoice: InvoiceData;
settings: Required<PDFGenerationSettings>;
}> = ({ invoice, settings }) => {
if (!invoice.notes) return null;
const isMinimal = settings.pdfTemplate === "minimal";
return (
<View
style={[
styles.notesContainer,
isMinimal ? minimalStyles.notesContainer : {},
]}
>
<View
style={[
styles.notesSection,
isMinimal ? minimalStyles.notesSection : {},
]}
>
<Text
style={[styles.notesTitle, isMinimal ? minimalStyles.notesTitle : {}]}
>
NOTES
</Text>
<Text
style={[
styles.notesContent,
isMinimal ? minimalStyles.notesContent : {},
]}
>
{invoice.notes}
</Text>
</View>
</View>
);
};
const Footer: React.FC<{ settings: Required<PDFGenerationSettings> }> = ({
settings,
}) => {
const isMinimal = settings.pdfTemplate === "minimal";
return (
<View style={[styles.footer, isMinimal ? minimalStyles.footer : {}]} fixed>
<View style={styles.footerLogo}>
{settings.pdfShowLogo && (
// eslint-disable-next-line jsx-a11y/alt-text -- @react-pdf/renderer Image does not support alt.
<Image
src="/beenvoice-logo.png"
style={{
width: 120,
height: 18,
marginRight: 8,
}}
/>
)}
<Text
style={{
fontSize: isMinimal ? 8 : 9,
fontFamily: "Helvetica",
color: "#6b7280",
marginLeft: settings.pdfShowLogo ? 8 : 0,
}}
>
{settings.pdfFooterText}
</Text>
</View>
{settings.pdfShowPageNumbers && (
<Text
style={[styles.pageNumber, isMinimal ? minimalStyles.pageNumber : {}]}
render={({ pageNumber, totalPages }) =>
`Page ${pageNumber} of ${totalPages}`
}
/>
)}
</View>
);
};
// Enhanced totals section component
const TotalsSection: React.FC<{
invoice: InvoiceData;
items: Array<NonNullable<InvoiceData["items"]>[0]>;
settings: Required<PDFGenerationSettings>;
}> = ({ 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 (
<View
style={[
styles.totalsContainer,
isMinimal ? minimalStyles.totalsContainer : {},
]}
>
<View
style={[
styles.totalsBox,
isMinimal
? {
...minimalStyles.totalsBox,
backgroundColor: "#ffffff",
borderTop: "1px solid #e5e7eb",
paddingHorizontal: 0,
}
: {},
]}
>
<Text
style={{
fontSize: isMinimal ? 8 : 11,
fontFamily: "Helvetica-Bold",
color: "#0f0f0f",
textAlign: isMinimal ? "left" : "center",
marginBottom: isMinimal ? 5 : 8,
paddingBottom: isMinimal ? 3 : 6,
}}
>
INVOICE SUMMARY
</Text>
<View
style={[styles.totalRow, isMinimal ? minimalStyles.totalRow : {}]}
>
<Text
style={[
styles.totalLabel,
isMinimal ? minimalStyles.totalLabel : {},
]}
>
Subtotal:
</Text>
<Text
style={[
styles.totalAmount,
isMinimal ? minimalStyles.totalAmount : {},
]}
>
{formatCurrency(subtotal, currency)}
</Text>
</View>
{invoice.taxRate > 0 && (
<View
style={[styles.totalRow, isMinimal ? minimalStyles.totalRow : {}]}
>
<Text
style={[
styles.totalLabel,
isMinimal ? minimalStyles.totalLabel : {},
]}
>
Tax ({invoice.taxRate}%):
</Text>
<Text
style={[
styles.totalAmount,
isMinimal ? minimalStyles.totalAmount : {},
]}
>
{formatCurrency(taxAmount, currency)}
</Text>
</View>
)}
<View
style={[
styles.finalTotalRow,
isMinimal ? minimalStyles.finalTotalRow : {},
]}
>
<Text
style={[
styles.finalTotalLabel,
isMinimal ? minimalStyles.finalTotalLabel : {},
]}
>
TOTAL:
</Text>
<Text
style={[
styles.finalTotalAmount,
isMinimal ? minimalStyles.finalTotalAmount : {},
{ color: settings.pdfAccentColor },
]}
>
{formatCurrency(total, currency)}
</Text>
</View>
<Text
style={[styles.itemCount, isMinimal ? minimalStyles.itemCount : {}]}
>
{items.length} line item{items.length !== 1 ? "s" : ""}
</Text>
</View>
</View>
);
};
// 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 (
<Document>
<Page
size="LETTER"
style={[styles.page, isMinimal ? minimalStyles.page : {}]}
>
<DenseHeader invoice={invoice} settings={settings} />
{items.length > 0 && (
<View
style={[
styles.tableContainer,
isMinimal ? minimalStyles.tableContainer : {},
]}
>
<TableHeader settings={settings} showRate={showRate} />
{items.map(
(item, index) =>
item && (
<View
key={`invoice-item-${index}`}
wrap={false}
style={[
styles.tableRow,
isMinimal ? minimalStyles.tableRow : {},
settings.pdfTemplate === "classic" && index % 2 === 0
? styles.tableRowAlt
: {},
]}
>
<Text
style={[
styles.tableCell,
isMinimal ? minimalStyles.tableCell : {},
styles.tableCellDate,
{ width: cols.date },
]}
>
{formatDate(item.date)}
</Text>
<Text
style={[
styles.tableCell,
isMinimal ? minimalStyles.tableCell : {},
styles.tableCellDescription,
isMinimal ? minimalStyles.tableCellDescription : {},
{ width: cols.description },
]}
>
{item.description}
</Text>
<Text
style={[
styles.tableCell,
isMinimal ? minimalStyles.tableCell : {},
styles.tableCellHours,
{ width: cols.hours },
]}
>
{item.hours}
</Text>
{showRate && (
<Text
style={[
styles.tableCell,
isMinimal ? minimalStyles.tableCell : {},
styles.tableCellRate,
{ width: cols.rate },
]}
>
{formatCurrency(item.rate, currency)}
</Text>
)}
<Text
style={[
styles.tableCell,
isMinimal ? minimalStyles.tableCell : {},
styles.tableCellAmount,
{ width: cols.amount },
]}
>
{formatCurrency(item.amount, currency)}
</Text>
</View>
),
)}
</View>
)}
<View
style={[
styles.bottomSection,
isMinimal ? minimalStyles.bottomSection : {},
]}
wrap={false}
>
{invoice.notes && (
<NotesSection invoice={invoice} settings={settings} />
)}
<TotalsSection invoice={invoice} items={items} settings={settings} />
</View>
<Footer settings={settings} />
</Page>
</Document>
);
};
// Export functions
export async function generateInvoicePDF(
invoice: InvoiceData,
settings?: PDFGenerationSettings,
): Promise<void> {
try {
// Validate invoice data
if (!invoice) {
throw new Error("Invoice data is required");
}
if (!invoice.invoiceNumber) {
throw new Error("Invoice number is required");
}
if (!invoice.client?.name) {
throw new Error("Client information is required");
}
// Generate PDF blob
const originalBlob = await pdf(
<InvoicePDF invoice={invoice} settings={settings} />,
).toBlob();
// Validate blob
if (!originalBlob || originalBlob.size === 0) {
throw new Error("Generated PDF is empty");
}
// Create a new blob with explicit MIME type to ensure proper PDF handling
const blob = new Blob([originalBlob], { type: "application/pdf" });
// Create filename with timestamp for uniqueness
const timestamp = new Date().toISOString().slice(0, 10);
const filename = `Invoice_${invoice.invoiceNumber}_${timestamp}.pdf`;
// Download the PDF with fallback support
downloadBlob(blob, filename);
} catch (error) {
// Log the actual error for debugging
console.error("PDF generation error:", error);
throw new Error("Failed to generate PDF. Please try again.");
}
}
// Additional utility function for generating PDF without downloading
export async function generateInvoicePDFBlob(
invoice: InvoiceData,
settings?: PDFGenerationSettings,
): Promise<Blob> {
try {
// Validate invoice data
if (!invoice) {
throw new Error("Invoice data is required");
}
if (!invoice.invoiceNumber) {
throw new Error("Invoice number is required");
}
if (!invoice.client?.name) {
throw new Error("Client information is required");
}
// Generate PDF blob
const originalBlob = await pdf(
<InvoicePDF invoice={invoice} settings={settings} />,
).toBlob();
// Validate blob
if (!originalBlob || originalBlob.size === 0) {
throw new Error("Generated PDF is empty");
}
// Create a new blob with explicit MIME type to ensure proper PDF handling
const blob = new Blob([originalBlob], { type: "application/pdf" });
return blob;
} catch (error) {
// Re-throw with consistent error handling
if (error instanceof Error) {
throw error;
}
throw new Error("Failed to generate PDF blob");
}
}