All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m6s
512 lines
13 KiB
TypeScript
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),
|
|
),
|
|
]);
|
|
}
|