Files
chronus/src/pdf/generateInvoicePDF.ts
Atridad Lahiji 0cd77677f2
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m6s
FINISHED
2026-01-17 15:56:25 -07:00

512 lines
13 KiB
TypeScript

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;
}
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;
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,
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/")) {
let uploadDir;
const envRootDir = process.env.ROOT_DIR
? process.env.ROOT_DIR
: import.meta.env.ROOT_DIR;
if (envRootDir) {
uploadDir = join(envRootDir, "uploads");
} else {
uploadDir =
process.env.UPLOAD_DIR ||
join(process.cwd(), "public", "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.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.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),
),
]);
}