This commit is contained in:
505
src/pdf/generateInvoicePDF.ts
Normal file
505
src/pdf/generateInvoicePDF.ts
Normal file
@@ -0,0 +1,505 @@
|
||||
import { h } from "vue";
|
||||
import { Document, Page, Text, View } from "@ceereals/vue-pdf";
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
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,
|
||||
statusBadge: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 6,
|
||||
fontSize: 9,
|
||||
fontWeight: "bold",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
alignSelf: "flex-start",
|
||||
} as Style,
|
||||
statusDraft: {
|
||||
backgroundColor: "#F3F4F6",
|
||||
color: "#6B7280",
|
||||
} as Style,
|
||||
statusSent: {
|
||||
backgroundColor: "#DBEAFE",
|
||||
color: "#1E40AF",
|
||||
} as Style,
|
||||
statusPaid: {
|
||||
backgroundColor: "#D1FAE5",
|
||||
color: "#065F46",
|
||||
} as Style,
|
||||
statusAccepted: {
|
||||
backgroundColor: "#D1FAE5",
|
||||
color: "#065F46",
|
||||
} as Style,
|
||||
statusVoid: {
|
||||
backgroundColor: "#FEE2E2",
|
||||
color: "#991B1B",
|
||||
} as Style,
|
||||
statusDeclined: {
|
||||
backgroundColor: "#FEE2E2",
|
||||
color: "#991B1B",
|
||||
} 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",
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusStyle = (status: string): Style => {
|
||||
const baseStyle = styles.statusBadge;
|
||||
switch (status) {
|
||||
case "draft":
|
||||
return { ...baseStyle, ...styles.statusDraft };
|
||||
case "sent":
|
||||
return { ...baseStyle, ...styles.statusSent };
|
||||
case "paid":
|
||||
case "accepted":
|
||||
return { ...baseStyle, ...styles.statusPaid };
|
||||
case "void":
|
||||
case "declined":
|
||||
return { ...baseStyle, ...styles.statusVoid };
|
||||
default:
|
||||
return { ...baseStyle, ...styles.statusDraft };
|
||||
}
|
||||
};
|
||||
|
||||
return h(Document, [
|
||||
h(
|
||||
Page,
|
||||
{ size: "A4", style: styles.page },
|
||||
[
|
||||
// Header
|
||||
h(View, { style: styles.header }, [
|
||||
h(View, { style: styles.headerLeft }, [
|
||||
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: getStatusStyle(invoice.status) }, [
|
||||
h(Text, invoice.status),
|
||||
]),
|
||||
]),
|
||||
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),
|
||||
),
|
||||
]),
|
||||
h(View, { style: styles.metaRow }, [
|
||||
h(Text, { style: styles.metaLabel }, "Due Date"),
|
||||
h(
|
||||
Text,
|
||||
{ style: styles.metaValue },
|
||||
formatDate(invoice.dueDate),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
|
||||
// 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),
|
||||
),
|
||||
]);
|
||||
}
|
||||
Reference in New Issue
Block a user