import { h } from "vue"; import { Document, Page, Text, View, Image } from "@ceereals/vue-pdf"; import { readFileSync, existsSync } from "fs"; import { join } from "path"; import type { Style } from "@react-pdf/types"; interface InvoiceItem { id: string; description: string; quantity: number; unitPrice: number; amount: number; } interface Client { name: string; email: string | null; street: string | null; city: string | null; state: string | null; zip: string | null; country: string | null; } interface Organization { name: string; street: string | null; city: string | null; state: string | null; zip: string | null; country: string | null; logoUrl?: string | null; } interface Invoice { number: string; type: string; status: string; issueDate: Date; dueDate: Date; currency: string; subtotal: number; discountValue: number | null; discountType: string | null; discountAmount: number | null; taxRate: number | null; taxAmount: number; total: number; notes: string | null; } interface InvoiceDocumentProps { invoice: Invoice; items: InvoiceItem[]; client: Client; organization: Organization; } const styles = { page: { padding: 60, fontFamily: "Helvetica", fontSize: 10, lineHeight: 1.6, color: "#1F2937", backgroundColor: "#FFFFFF", } as Style, header: { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 45, paddingBottom: 24, borderBottomWidth: 2, borderBottomColor: "#E5E7EB", } as Style, headerLeft: { flex: 1, maxWidth: 280, } as Style, logo: { height: 40, marginBottom: 8, objectFit: "contain", objectPosition: "left", } as Style, headerRight: { flex: 1, alignItems: "flex-end", } as Style, organizationName: { fontSize: 22, fontWeight: "bold", marginBottom: 8, color: "#1F2937", letterSpacing: -0.5, } as Style, organizationAddress: { fontSize: 9, color: "#6B7280", lineHeight: 1.5, marginBottom: 12, } as Style, invoiceTypeContainer: { alignItems: "flex-end", marginBottom: 16, } as Style, invoiceType: { fontSize: 36, fontWeight: "normal", color: "#9CA3AF", textTransform: "uppercase", letterSpacing: 6, lineHeight: 1, } as Style, metaContainer: { alignItems: "flex-end", marginTop: 4, } as Style, metaRow: { flexDirection: "row", justifyContent: "flex-end", alignItems: "center", marginBottom: 5, minWidth: 220, } as Style, metaLabel: { color: "#6B7280", fontSize: 9, textTransform: "uppercase", letterSpacing: 0.5, marginRight: 12, width: 70, textAlign: "right", } as Style, metaValue: { fontFamily: "Helvetica-Bold", fontSize: 10, color: "#1F2937", flex: 1, textAlign: "right", } as Style, billToSection: { marginBottom: 40, } as Style, sectionLabel: { fontSize: 9, fontWeight: "bold", textTransform: "uppercase", letterSpacing: 1.5, color: "#9CA3AF", marginBottom: 12, } as Style, clientName: { fontSize: 16, fontWeight: "bold", marginBottom: 4, color: "#1F2937", } as Style, clientEmail: { fontSize: 10, color: "#6B7280", } as Style, clientAddress: { fontSize: 10, color: "#6B7280", lineHeight: 1.5, } as Style, table: { marginBottom: 40, } as Style, tableHeader: { flexDirection: "row", backgroundColor: "#F9FAFB", paddingVertical: 12, paddingHorizontal: 16, borderTopLeftRadius: 8, borderTopRightRadius: 8, } as Style, tableHeaderCell: { fontSize: 9, fontWeight: "bold", textTransform: "uppercase", letterSpacing: 1, color: "#6B7280", } as Style, tableRow: { flexDirection: "row", paddingVertical: 16, paddingHorizontal: 16, borderBottomWidth: 1, borderBottomColor: "#F3F4F6", } as Style, tableCell: { fontSize: 10, color: "#1F2937", } as Style, colDescription: { flex: 3, paddingRight: 16, } as Style, colQty: { width: 60, textAlign: "center", } as Style, colPrice: { width: 90, textAlign: "right", paddingRight: 16, } as Style, colAmount: { width: 100, textAlign: "right", fontFamily: "Helvetica-Bold", } as Style, totalsSection: { flexDirection: "row", justifyContent: "flex-end", marginTop: 20, marginBottom: 50, } as Style, totalsBox: { width: 280, backgroundColor: "#F9FAFB", padding: 20, borderRadius: 8, } as Style, totalRow: { flexDirection: "row", justifyContent: "space-between", marginBottom: 10, fontSize: 10, } as Style, totalLabel: { color: "#6B7280", fontSize: 10, } as Style, totalValue: { fontFamily: "Helvetica-Bold", color: "#1F2937", fontSize: 10, } as Style, divider: { borderBottomWidth: 2, borderBottomColor: "#E5E7EB", marginVertical: 12, } as Style, grandTotalRow: { flexDirection: "row", justifyContent: "space-between", paddingTop: 8, } as Style, grandTotalLabel: { fontSize: 16, fontWeight: "bold", color: "#1F2937", } as Style, grandTotalValue: { fontSize: 18, fontWeight: "bold", color: "#2563EB", } as Style, notesSection: { marginTop: 30, paddingTop: 30, borderTopWidth: 2, borderTopColor: "#E5E7EB", } as Style, notesText: { fontSize: 9, color: "#6B7280", lineHeight: 1.6, whiteSpace: "pre-wrap", } as Style, }; export function createInvoiceDocument(props: InvoiceDocumentProps) { const { invoice, items, client, organization } = props; const formatCurrency = (amount: number) => { return new Intl.NumberFormat("en-US", { style: "currency", currency: invoice.currency, }).format(amount / 100); }; const formatDate = (date: Date) => { return new Date(date).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric", }); }; return h(Document, [ h( Page, { size: "A4", style: styles.page }, [ // Header h(View, { style: styles.header }, [ h(View, { style: styles.headerLeft }, [ (() => { if (organization.logoUrl) { try { let logoPath; // Handle uploads directory which might be external to public/ if (organization.logoUrl.startsWith("/uploads/")) { const dataDir = process.env.DATA_DIR ? process.env.DATA_DIR : import.meta.env.DATA_DIR; if (!dataDir) { throw new Error( "DATA_DIR environment variable is not set", ); } const uploadDir = join(dataDir, "uploads"); const filename = organization.logoUrl.replace( "/uploads/", "", ); logoPath = join(uploadDir, filename); } else { logoPath = join( process.cwd(), "public", organization.logoUrl, ); } if (existsSync(logoPath)) { const ext = logoPath.split(".").pop()?.toLowerCase(); if (ext === "png" || ext === "jpg" || ext === "jpeg") { return h(Image, { src: { data: readFileSync(logoPath), format: ext === "png" ? "png" : "jpg", }, style: styles.logo, }); } } } catch (e) { // Ignore errors } } return null; })(), h(Text, { style: styles.organizationName }, organization.name), organization.street || organization.city ? h( View, { style: styles.organizationAddress }, [ organization.street ? h(Text, organization.street) : null, organization.city || organization.state || organization.zip ? h( Text, [ organization.city, organization.state, organization.zip, ] .filter(Boolean) .join(", "), ) : null, organization.country ? h(Text, organization.country) : null, ].filter(Boolean), ) : null, ]), h(View, { style: styles.headerRight }, [ h(View, { style: styles.invoiceTypeContainer }, [ h(Text, { style: styles.invoiceType }, invoice.type), ]), h(View, { style: styles.metaContainer }, [ h(View, { style: styles.metaRow }, [ h(Text, { style: styles.metaLabel }, "Number"), h(Text, { style: styles.metaValue }, invoice.number), ]), h(View, { style: styles.metaRow }, [ h(Text, { style: styles.metaLabel }, "Date"), h( Text, { style: styles.metaValue }, formatDate(invoice.issueDate), ), ]), invoice.type !== "quote" ? h(View, { style: styles.metaRow }, [ h(Text, { style: styles.metaLabel }, "Due Date"), h( Text, { style: styles.metaValue }, formatDate(invoice.dueDate), ), ]) : null, ]), ]), ]), // Bill To h( View, { style: styles.billToSection }, [ h(Text, { style: styles.sectionLabel }, "Bill To"), h(Text, { style: styles.clientName }, client.name), client.street || client.city || client.state || client.zip || client.country ? h( View, { style: styles.clientAddress }, [ client.street ? h(Text, client.street) : null, client.city || client.state || client.zip ? h( Text, [client.city, client.state, client.zip] .filter(Boolean) .join(", "), ) : null, client.country ? h(Text, client.country) : null, ].filter(Boolean), ) : null, client.email ? h(Text, { style: styles.clientEmail }, client.email) : null, ].filter(Boolean), ), // Items Table h(View, { style: styles.table }, [ h(View, { style: styles.tableHeader }, [ h( Text, { style: { ...styles.tableHeaderCell, ...styles.colDescription }, }, "Description", ), h( Text, { style: { ...styles.tableHeaderCell, ...styles.colQty } }, "Qty", ), h( Text, { style: { ...styles.tableHeaderCell, ...styles.colPrice } }, "Unit Price", ), h( Text, { style: { ...styles.tableHeaderCell, ...styles.colAmount } }, "Amount", ), ]), ...items.map((item) => h(View, { key: item.id, style: styles.tableRow }, [ h( Text, { style: { ...styles.tableCell, ...styles.colDescription } }, item.description, ), h( Text, { style: { ...styles.tableCell, ...styles.colQty } }, item.quantity.toString(), ), h( Text, { style: { ...styles.tableCell, ...styles.colPrice } }, formatCurrency(item.unitPrice), ), h( Text, { style: { ...styles.tableCell, ...styles.colAmount } }, formatCurrency(item.amount), ), ]), ), ]), // Totals h(View, { style: styles.totalsSection }, [ h( View, { style: styles.totalsBox }, [ h(View, { style: styles.totalRow }, [ h(Text, { style: styles.totalLabel }, "Subtotal"), h( Text, { style: styles.totalValue }, formatCurrency(invoice.subtotal), ), ]), (invoice.discountAmount ?? 0) > 0 ? h(View, { style: styles.totalRow }, [ h( Text, { style: styles.totalLabel }, `Discount${ invoice.discountType === "percentage" ? ` (${invoice.discountValue}%)` : "" }`, ), h( Text, { style: styles.totalValue }, `-${formatCurrency(invoice.discountAmount ?? 0)}`, ), ]) : null, (invoice.taxRate ?? 0) > 0 ? h(View, { style: styles.totalRow }, [ h( Text, { style: styles.totalLabel }, `Tax (${invoice.taxRate}%)`, ), h( Text, { style: styles.totalValue }, formatCurrency(invoice.taxAmount), ), ]) : null, h(View, { style: styles.divider }), h(View, { style: styles.grandTotalRow }, [ h(Text, { style: styles.grandTotalLabel }, "Total"), h( Text, { style: styles.grandTotalValue }, formatCurrency(invoice.total), ), ]), ].filter(Boolean), ), ]), // Notes invoice.notes ? h(View, { style: styles.notesSection }, [ h(Text, { style: styles.sectionLabel }, "Notes"), h(Text, { style: styles.notesText }, invoice.notes), ]) : null, ].filter(Boolean), ), ]); }