PDF Export Report
This commit is contained in:
@@ -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
|
||||
};
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
66
templates/report-shell-template.html
Normal file
66
templates/report-shell-template.html
Normal 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>
|
||||
Reference in New Issue
Block a user