PDF Export Report

This commit is contained in:
2026-06-10 17:21:29 -05:00
parent ba6019751f
commit 8959064080
3 changed files with 246 additions and 1 deletions

View File

@@ -458,6 +458,7 @@ export function injectReportsControls() {
<option value="Cash" ${plAccountingMethod === 'Cash' ? 'selected' : ''}>Cash</option>
</select></div>
<button onclick="window.accountingView.loadProfitLoss()" class="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700">Run</button>
<button onclick="window.accountingView.exportProfitLossPdf()" class="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-md text-sm font-medium border border-gray-300">📄 PDF</button>
</div>
<div id="pl-result"></div>
</div>
@@ -474,6 +475,7 @@ export function injectReportsControls() {
<option value="Cash" ${bsAccountingMethod === 'Cash' ? 'selected' : ''}>Cash</option>
</select></div>
<button onclick="window.accountingView.loadBalanceSheet()" class="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700">Run</button>
<button onclick="window.accountingView.exportBalanceSheetPdf()" class="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-md text-sm font-medium border border-gray-300">📄 PDF</button>
</div>
<div id="bs-result"></div>
</div>
@@ -490,6 +492,7 @@ export function injectReportsControls() {
<option value="Cash" ${tsAccountingMethod === 'Cash' ? 'selected' : ''}>Cash</option>
</select></div>
<button onclick="window.accountingView.loadTaxSummary()" class="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700">Run</button>
<button onclick="window.accountingView.exportTaxSummaryPdf()" class="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-md text-sm font-medium border border-gray-300">📄 PDF</button>
</div>
<div id="ts-result"></div>
</div>
@@ -617,6 +620,31 @@ export function exportCustomerRevenuePdf() {
window.open(url, '_blank');
}
export function exportProfitLossPdf() {
const startEl = document.getElementById('pl-start');
const endEl = document.getElementById('pl-end');
const method = document.getElementById('pl-method')?.value || 'Accrual';
if (!startEl?.value || !endEl?.value) return alert('Please select start and end dates first.');
window.open(`/api/accounting/reports/profit-loss/pdf?startDate=${startEl.value}&endDate=${endEl.value}&accountingMethod=${method}`, '_blank');
}
export function exportBalanceSheetPdf() {
const asOf = document.getElementById('bs-asof');
const method = document.getElementById('bs-method')?.value || 'Accrual';
if (!asOf?.value) return alert('Please select an as-of date first.');
window.open(`/api/accounting/reports/balance-sheet/pdf?asOfDate=${asOf.value}&accountingMethod=${method}`, '_blank');
}
export function exportTaxSummaryPdf() {
const monthEl = document.getElementById('ts-month');
const method = document.getElementById('ts-method')?.value || 'Accrual';
if (!monthEl?.value) return alert('Please select a month first.');
const [y, m] = monthEl.value.split('-').map(Number);
const startDate = firstOfMonthISO(y, m - 1);
const endDate = lastOfMonthISO(y, m - 1);
window.open(`/api/accounting/reports/tax-summary/pdf?startDate=${startDate}&endDate=${endDate}&accountingMethod=${method}`, '_blank');
}
function renderQboReport(report) {
if (!report || !report.Header) return `<p class="text-sm text-gray-500">No report data.</p>`;
const cols = (report.Columns && report.Columns.Column) || [];
@@ -1388,5 +1416,8 @@ window.accountingView = {
recordTaxPayment,
markTaxPaidExternal,
loadCustomerRevenue,
exportCustomerRevenuePdf
exportCustomerRevenuePdf,
exportProfitLossPdf,
exportBalanceSheetPdf,
exportTaxSummaryPdf
};

View File

@@ -242,6 +242,154 @@ router.get('/reports/customer-revenue/pdf', async (req, res) => {
}
});
// ─── Shared Report PDF helper ──────────────────────────────────────
function reportToHtml(report) {
const cols = (report.Columns && report.Columns.Column) || [];
const headHtml = cols.map(c =>
`<th>${escapeHtmlReport(c.ColTitle || '')}</th>`
).join('');
function buildRows(rows, depth) {
if (!rows) return '';
const arr = Array.isArray(rows) ? rows : [rows];
let html = '';
for (const row of arr) {
const isSection = row.type === 'Section' || row.Rows || row.Summary;
const indent = depth * 16;
if (row.Header && row.Header.ColData) {
const cells = row.Header.ColData.map((c, i) =>
`<td style="padding-left:${i === 0 ? 12 + indent : 0}px;${i === 0 ? ' font-weight:bold;' : ''}">${i === 0 ? escapeHtmlReport(c.value || '') : ''}</td>`
).join('');
html += `<tr class="section-header">${cells}</tr>`;
}
if (isSection && row.Rows && row.Rows.Row)
html += buildRows(row.Rows.Row, depth + 1);
if (row.Summary && row.Summary.ColData) {
const cells = row.Summary.ColData.map((c, i) =>
`<td style="padding-left:${i === 0 ? 12 + indent : 0}px;" class="cell-bold">${escapeHtmlReport(c.value || '')}</td>`
).join('');
html += `<tr class="summary-row">${cells}</tr>`;
}
if (!isSection && row.ColData) {
const cells = row.ColData.map((c, i) =>
`<td style="padding-left:${i === 0 ? 12 + indent : 0}px;">${escapeHtmlReport(c.value || '')}</td>`
).join('');
html += `<tr>${cells}</tr>`;
}
}
return html;
}
const bodyHtml = buildRows(report.Rows && report.Rows.Row ? report.Rows.Row : null, 0);
return { headHtml, bodyHtml };
}
function escapeHtmlReport(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
async function renderReportPdf(res, title, meta, filename, reportObj) {
const templatePath = path.join(__dirname, '..', '..', 'templates', 'report-shell-template.html');
let html = await fs.readFile(templatePath, 'utf-8');
const logoHTML = await getLogoHtml();
const { headHtml, bodyHtml } = reportToHtml(reportObj);
const generated = new Date().toISOString().split('T')[0];
html = html
.replace('{{LOGO_HTML}}', logoHTML)
.replace('{{COMPANY_NAME}}', 'Bay Area Affiliates, Inc.')
.replace('{{COMPANY_ADDRESS}}', '1001 Blucher Street<br>Corpus Christi, Texas 78401')
.replace('{{SLOGAN}}', 'Providing IT Services and Support in South Texas Since 1996')
.replace('{{REPORT_TITLE}}', title)
.replace('{{REPORT_META}}', meta)
.replace('{{TABLE_HEAD}}', `<tr>${headHtml}</tr>`)
.replace('{{REPORT_BODY}}', bodyHtml)
.replace('{{GENERATED_DATE}}', generated);
const pdf = await generatePdfFromHtml(html);
const sanitized = filename.replace(/[^a-zA-Z0-9._-]/g, '-');
res.set({
'Content-Type': 'application/pdf',
'Content-Length': pdf.length,
'Content-Disposition': `attachment; filename="${sanitized}.pdf"`
});
res.end(pdf, 'binary');
}
// ─── P&L PDF ───────────────────────────────────────────────────────
router.get('/reports/profit-loss/pdf', async (req, res) => {
try {
const startDate = req.query.startDate;
const endDate = req.query.endDate;
const method = req.query.accountingMethod || 'Accrual';
if (!startDate || !endDate) return res.status(400).json({ error: 'startDate and endDate required' });
const report = await accountingService.getProfitAndLoss({ startDate, endDate, accountingMethod: method });
await renderReportPdf(res,
'PROFIT & LOSS REPORT',
`Period: ${startDate} to ${endDate} · ${method}`,
`Profit-Loss-${startDate}-to-${endDate}`,
report
);
} catch (err) {
console.error('p&l pdf error:', err.message);
res.status(500).json({ error: err.message });
}
});
// ─── Balance Sheet PDF ─────────────────────────────────────────────
router.get('/reports/balance-sheet/pdf', async (req, res) => {
try {
const asOfDate = req.query.asOfDate;
const method = req.query.accountingMethod || 'Accrual';
if (!asOfDate) return res.status(400).json({ error: 'asOfDate required' });
const report = await accountingService.getBalanceSheet({ asOfDate, accountingMethod: method });
await renderReportPdf(res,
'BALANCE SHEET',
`As of: ${asOfDate} · ${method}`,
`Balance-Sheet-${asOfDate}`,
report
);
} catch (err) {
console.error('balance-sheet pdf error:', err.message);
res.status(500).json({ error: err.message });
}
});
// ─── Sales Tax PDF ──────────────────────────────────────────────────
router.get('/reports/tax-summary/pdf', async (req, res) => {
try {
const startDate = req.query.startDate;
const endDate = req.query.endDate;
const method = req.query.accountingMethod || 'Accrual';
if (!startDate || !endDate) return res.status(400).json({ error: 'startDate and endDate required' });
const report = await accountingService.getTaxSummary({ startDate, endDate, accountingMethod: method });
await renderReportPdf(res,
'SALES TAX LIABILITY',
`Period: ${startDate} to ${endDate} · ${method}`,
`Sales-Tax-${startDate}-to-${endDate}`,
report
);
} catch (err) {
console.error('tax-summary pdf error:', err.message);
res.status(500).json({ error: err.message });
}
});
// ════════════════════════════════════════════════════════════════════
// Phase 2 Lieferung 1 — Sync + Cache-Reads
// ════════════════════════════════════════════════════════════════════

View File

@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: Arial, sans-serif; font-size: 14px; line-height: 1.6; color: #333; }
.container { max-width: 8.5in; margin: 0 auto; padding: 20px; }
.header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 20px; padding-bottom: 20px; border-bottom: 2px solid #333; }
.company-info { display: flex; align-items: flex-start; gap: 15px; }
.logo { width: 50px; height: 50px; }
.company-details h1 { font-size: 16px; font-weight: normal; margin-bottom: 2px; }
.company-details p { font-size: 14px; line-height: 1.4; }
.tagline { text-align: right; font-style: italic; font-size: 14px; margin-bottom: 20px; }
.document-type { font-size: 24px; font-weight: bold; color: #333; margin-bottom: 10px; }
.report-meta { margin-bottom: 20px; }
.report-meta p { font-size: 13px; color: #555; }
.items-table { width: 100%; border-collapse: collapse; margin: 20px 0; font-size: 12px; }
.items-table th { background-color: #f5f5f5; border: 1px solid #000; padding: 6px 8px; font-weight: bold; text-align: right; }
.items-table th:first-child { text-align: left; }
.items-table td { border: 1px solid #000; padding: 6px 8px; text-align: right; }
.items-table td:first-child { text-align: left; }
.items-table td.cell-bold { font-weight: bold; }
.items-table tr.section-header td { background-color: #f0f0f0; font-weight: bold; }
.items-table tr.summary-row td { font-weight: bold; border-top-width: 2px; }
tr { page-break-inside: avoid; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="company-info">
{{LOGO_HTML}}
<div class="company-details">
<h1>{{COMPANY_NAME}}</h1>
<p>{{COMPANY_ADDRESS}}</p>
</div>
</div>
<div>
<div class="tagline">
<em>{{SLOGAN}}</em>
</div>
</div>
</div>
<div class="document-type">{{REPORT_TITLE}}</div>
<div class="report-meta">
<p>{{REPORT_META}}</p>
</div>
<table class="items-table">
<thead>
{{TABLE_HEAD}}
</thead>
<tbody>
{{REPORT_BODY}}
</tbody>
</table>
<p style="text-align:right; font-size:11px; color:#888; margin-top:10px;">
Generated: {{GENERATED_DATE}}
</p>
</div>
</body>
</html>