Refactor expense form and add PDF generation functionality

- Update expense form fields (merchant->establishmentName, amount->price)
- Add PDF generation with Puppeteer integration
- Create PDFOptionsModal component for export options
- Update expense form validation and UI layout
- Add server API endpoint for PDF generation
This commit is contained in:
2025-07-09 22:23:50 -04:00
parent b6d71faf5f
commit 893927d4b1
6 changed files with 901 additions and 189 deletions

View File

@@ -3,12 +3,14 @@ import { getExpenseById } from '@/server/utils/nocodb';
import { processExpenseWithCurrency } from '@/server/utils/currency';
import { createError } from 'h3';
import { formatDate } from '@/utils/dateUtils';
import puppeteer from 'puppeteer';
interface PDFOptions {
documentName: string;
subheader?: string;
groupBy: 'none' | 'payer' | 'category' | 'date';
includeReceipts: boolean;
includeReceiptContents: boolean;
includeSummary: boolean;
includeDetails: boolean;
pageFormat: 'A4' | 'Letter' | 'Legal';
@@ -77,19 +79,22 @@ export default defineEventHandler(async (event) => {
console.log('[expenses/generate-pdf] Successfully calculated totals:', totals);
console.log('[expenses/generate-pdf] Options received:', options);
// Generate PDF content
const pdfContent = generatePDFContent(expenses, options, totals);
// Generate HTML content
const htmlContent = generateHTMLContent(expenses, options, totals);
// Convert HTML to PDF using Puppeteer
const pdfBuffer = await generatePDFFromHTML(htmlContent, options);
// Return PDF as base64 for download
const pdfBase64 = Buffer.from(pdfContent).toString('base64');
const pdfBase64 = pdfBuffer.toString('base64');
return {
success: true,
data: {
filename: `${options.documentName.replace(/[^a-zA-Z0-9]/g, '_')}.pdf`,
filename: `${options.documentName.replace(/[^a-zA-Z0-9\-_\s]/g, '_')}.pdf`,
content: pdfBase64,
mimeType: 'application/pdf',
size: pdfContent.length
size: pdfBuffer.length
}
};
@@ -132,7 +137,7 @@ function getGroupingLabel(groupBy: string): string {
}
}
function generatePDFContent(expenses: Expense[], options: PDFOptions, totals: any): string {
function generateHTMLContent(expenses: Expense[], options: PDFOptions, totals: any): string {
// Generate HTML content that can be converted to PDF
const html = `
<!DOCTYPE html>
@@ -197,7 +202,7 @@ function generateExpenseTable(expenses: Expense[], options: PDFOptions): string
<th>Payer</th>
<th>Amount</th>
<th>Payment Method</th>
${options.includeDetails ? '<th>Description</th>' : ''}
${options.includeReceiptContents ? '<th>Description</th>' : ''}
</tr>
</thead>
<tbody>
@@ -219,7 +224,7 @@ function generateExpenseTable(expenses: Expense[], options: PDFOptions): string
// Group header
tableHTML += `
<tr class="group-header">
<td colspan="${options.includeDetails ? '7' : '6'}">${groupKey} (${groupExpenses.length} expenses - €${groupTotal.toFixed(2)})</td>
<td colspan="${options.includeReceiptContents ? '7' : '6'}">${groupKey} (${groupExpenses.length} expenses - €${groupTotal.toFixed(2)})</td>
</tr>
`;
@@ -250,7 +255,7 @@ function generateExpenseRow(expense: Expense, options: PDFOptions): string {
<td>${expense.Payer || 'N/A'}</td>
<td>€${expense.PriceNumber ? expense.PriceNumber.toFixed(2) : '0.00'}</td>
<td>${expense['Payment Method'] || 'N/A'}</td>
${options.includeDetails ? `<td>${description}</td>` : ''}
${options.includeReceiptContents ? `<td>${description}</td>` : ''}
</tr>
`;
}
@@ -281,3 +286,78 @@ function groupExpenses(expenses: Expense[], groupBy: string): Record<string, Exp
return groups;
}
async function generatePDFFromHTML(htmlContent: string, options: PDFOptions): Promise<Buffer> {
let browser;
try {
console.log('[expenses/generate-pdf] Launching Puppeteer browser...');
// Launch browser with optimized settings
browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-accelerated-2d-canvas',
'--no-first-run',
'--no-zygote',
'--disable-gpu'
]
});
const page = await browser.newPage();
// Set content with proper encoding
await page.setContent(htmlContent, {
waitUntil: 'networkidle0',
timeout: 30000
});
// Get page format dimensions
const format = getPageFormat(options.pageFormat);
console.log('[expenses/generate-pdf] Generating PDF with format:', format);
// Generate PDF with proper options
const pdfUint8Array = await page.pdf({
format: format.format,
printBackground: true,
margin: {
top: '20mm',
right: '15mm',
bottom: '20mm',
left: '15mm'
},
preferCSSPageSize: true
});
// Convert Uint8Array to Buffer
const pdfBuffer = Buffer.from(pdfUint8Array);
console.log('[expenses/generate-pdf] PDF generated successfully, size:', pdfBuffer.length, 'bytes');
return pdfBuffer;
} catch (error: any) {
console.error('[expenses/generate-pdf] Puppeteer error:', error);
throw new Error(`PDF generation failed: ${error?.message || 'Unknown error'}`);
} finally {
if (browser) {
await browser.close();
}
}
}
function getPageFormat(pageFormat: string): { format: any } {
switch (pageFormat) {
case 'Letter':
return { format: 'letter' };
case 'Legal':
return { format: 'legal' };
case 'A4':
default:
return { format: 'a4' };
}
}