invoice-craft
Version:
Customizable, browser-first invoice PDF generator library with modern TypeScript API
364 lines (326 loc) • 9.94 kB
JavaScript
import { calculateTotals } from "../core/calculations";
import { getLabels } from "../utils/localization";
export function generatePreviewHTML(invoice, options = {}) {
const totals = calculateTotals(invoice);
const labels = getLabels();
const theme = options.theme || 'light';
const responsive = options.responsive !== false;
const showGrid = options.showGrid || false;
const styles = generatePreviewStyles(theme, responsive, showGrid);
const content = generatePreviewContent(invoice, totals, labels);
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Invoice Preview - ${invoice.invoiceNumber}</title>
${options.includeStyles !== false ? `<style>${styles}</style>` : ''}
</head>
<body>
<div class="invoice-preview ${theme}-theme ${showGrid ? 'show-grid' : ''}">
${content}
</div>
</body>
</html>`;
}
function generatePreviewStyles(theme, responsive, showGrid) {
const baseStyles = `
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: ${theme === 'dark' ? '#e0e0e0' : '#333'};
background-color: ${theme === 'dark' ? '#1a1a1a' : '#f5f5f5'};
padding: 20px;
}
.invoice-preview {
max-width: 800px;
margin: 0 auto;
background: ${theme === 'dark' ? '#2d2d2d' : '#ffffff'};
padding: 40px;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
position: relative;
}
${showGrid ? `
.invoice-preview::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
linear-gradient(rgba(0,0,0,0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,0,0,0.1) 1px, transparent 1px);
background-size: 20px 20px;
pointer-events: none;
opacity: 0.3;
}` : ''}
.invoice-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 40px;
border-bottom: 2px solid ${theme === 'dark' ? '#404040' : '#e0e0e0'};
padding-bottom: 20px;
}
.company-info h1 {
font-size: 2.5em;
color: var(--brand-color, #1976d2);
margin-bottom: 10px;
}
.company-info p {
margin: 5px 0;
color: ${theme === 'dark' ? '#b0b0b0' : '#666'};
}
.invoice-details {
text-align: right;
}
.invoice-details h2 {
font-size: 2em;
color: var(--brand-color, #1976d2);
margin-bottom: 10px;
}
.invoice-details p {
margin: 5px 0;
font-size: 1.1em;
}
.parties {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 40px;
margin-bottom: 40px;
}
.party h3 {
font-size: 1.3em;
margin-bottom: 15px;
color: var(--brand-color, #1976d2);
border-bottom: 1px solid ${theme === 'dark' ? '#404040' : '#e0e0e0'};
padding-bottom: 5px;
}
.party p {
margin: 8px 0;
color: ${theme === 'dark' ? '#b0b0b0' : '#666'};
}
.items-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 30px;
background: ${theme === 'dark' ? '#333' : '#fff'};
}
.items-table th,
.items-table td {
padding: 15px;
text-align: left;
border-bottom: 1px solid ${theme === 'dark' ? '#404040' : '#e0e0e0'};
}
.items-table th {
background-color: var(--brand-color, #1976d2);
color: white;
font-weight: 600;
text-transform: uppercase;
font-size: 0.9em;
letter-spacing: 0.5px;
}
.items-table td {
color: ${theme === 'dark' ? '#e0e0e0' : '#333'};
}
.items-table .number {
text-align: right;
font-family: 'Courier New', monospace;
}
.totals {
margin-left: auto;
width: 300px;
background: ${theme === 'dark' ? '#333' : '#f9f9f9'};
padding: 20px;
border-radius: 8px;
}
.totals .total-row {
display: flex;
justify-content: space-between;
margin: 10px 0;
padding: 8px 0;
}
.totals .total-row.final {
border-top: 2px solid var(--brand-color, #1976d2);
font-weight: bold;
font-size: 1.2em;
color: var(--brand-color, #1976d2);
}
.notes-terms {
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid ${theme === 'dark' ? '#404040' : '#e0e0e0'};
}
.notes-terms h4 {
color: var(--brand-color, #1976d2);
margin-bottom: 10px;
}
.notes-terms p {
color: ${theme === 'dark' ? '#b0b0b0' : '#666'};
line-height: 1.6;
}
`;
const responsiveStyles = responsive ? `
@media (max-width: 768px) {
.invoice-preview {
padding: 20px;
margin: 10px;
}
.invoice-header {
flex-direction: column;
gap: 20px;
}
.invoice-details {
text-align: left;
}
.parties {
grid-template-columns: 1fr;
gap: 20px;
}
.items-table {
font-size: 0.9em;
}
.items-table th,
.items-table td {
padding: 10px 8px;
}
.totals {
width: 100%;
margin: 20px 0;
}
}
@media (max-width: 480px) {
.company-info h1 {
font-size: 2em;
}
.invoice-details h2 {
font-size: 1.5em;
}
.items-table {
font-size: 0.8em;
}
}
` : '';
return baseStyles + responsiveStyles;
}
function generatePreviewContent(invoice, totals, labels) {
var _a, _b, _c;
const brandColor = invoice.from.brandColor || '#1976d2';
return `
<style>
:root {
--brand-color: ${brandColor};
}
</style>
<div class="invoice-header">
<div class="company-info">
${invoice.from.logoUrl ? `<img src="${invoice.from.logoUrl}" alt="Logo" style="max-height: 60px; margin-bottom: 15px;">` : ''}
<h1>${invoice.from.name}</h1>
<p>${((_a = invoice.from.address) === null || _a === void 0 ? void 0 : _a.replace(/\n/g, '<br>')) || ''}</p>
${invoice.from.email ? `<p>Email: ${invoice.from.email}</p>` : ''}
${invoice.from.phone ? `<p>Phone: ${invoice.from.phone}</p>` : ''}
</div>
<div class="invoice-details">
<h2>${labels.invoice}</h2>
<p><strong>${labels.invoiceNumber}:</strong> ${invoice.invoiceNumber}</p>
<p><strong>${labels.date}:</strong> ${invoice.invoiceDate}</p>
${invoice.dueDate ? `<p><strong>${labels.dueDate}:</strong> ${invoice.dueDate}</p>` : ''}
</div>
</div>
<div class="parties">
<div class="party">
<h3>${labels.billTo}</h3>
<p><strong>${invoice.to.name}</strong></p>
<p>${((_b = invoice.to.address) === null || _b === void 0 ? void 0 : _b.replace(/\n/g, '<br>')) || ''}</p>
${invoice.to.email ? `<p>Email: ${invoice.to.email}</p>` : ''}
${invoice.to.phone ? `<p>Phone: ${invoice.to.phone}</p>` : ''}
</div>
<div class="party">
<h3>${labels.billFrom}</h3>
<p><strong>${invoice.from.name}</strong></p>
<p>${((_c = invoice.from.address) === null || _c === void 0 ? void 0 : _c.replace(/\n/g, '<br>')) || ''}</p>
${invoice.from.email ? `<p>Email: ${invoice.from.email}</p>` : ''}
${invoice.from.phone ? `<p>Phone: ${invoice.from.phone}</p>` : ''}
</div>
</div>
<table class="items-table">
<thead>
<tr>
<th>${labels.description}</th>
<th>${labels.quantity}</th>
<th>${labels.unitPrice}</th>
<th>${labels.tax}</th>
<th>${labels.total}</th>
</tr>
</thead>
<tbody>
${invoice.items.map(item => {
const itemTotal = item.quantity * item.unitPrice;
const taxAmount = itemTotal * (item.taxRate || 0);
const totalWithTax = itemTotal + taxAmount;
return `
<tr>
<td>${item.description}</td>
<td class="number">${item.quantity}</td>
<td class="number">${formatCurrency(item.unitPrice, invoice.currency)}</td>
<td class="number">${((item.taxRate || 0) * 100).toFixed(1)}%</td>
<td class="number">${formatCurrency(totalWithTax, invoice.currency)}</td>
</tr>
`;
}).join('')}
</tbody>
</table>
<div class="totals">
<div class="total-row">
<span>${labels.subtotal}:</span>
<span>${formatCurrency(totals.subtotal, invoice.currency)}</span>
</div>
${totals.totalTax > 0 ? `
<div class="total-row">
<span>${labels.tax}:</span>
<span>${formatCurrency(totals.totalTax, invoice.currency)}</span>
</div>
` : ''}
${invoice.discount ? `
<div class="total-row">
<span>${labels.discount}:</span>
<span>-${formatCurrency(invoice.discount, invoice.currency)}</span>
</div>
` : ''}
<div class="total-row final">
<span>${labels.total}:</span>
<span>${formatCurrency(totals.total, invoice.currency)}</span>
</div>
</div>
${invoice.notes || invoice.terms ? `
<div class="notes-terms">
${invoice.notes ? `
<div>
<h4>Notes:</h4>
<p>${invoice.notes}</p>
</div>
` : ''}
${invoice.terms ? `
<div style="margin-top: 20px;">
<h4>Terms & Conditions:</h4>
<p>${invoice.terms}</p>
</div>
` : ''}
</div>
` : ''}
`;
}
function formatCurrency(amount, currency = 'USD') {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency
}).format(amount);
}