pdf export

This commit is contained in:
2026-07-26 17:05:48 -05:00
parent 363a14d49b
commit e086fe8931
4 changed files with 473 additions and 27 deletions

View File

@@ -149,6 +149,8 @@ export function renderReportsView() {
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.reportsView.exportArAgingCsv()"
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">⬇ Export CSV</button>
<button onclick="window.reportsView.exportArAgingPdf()"
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">📄 Export PDF</button>
<label class="flex items-center gap-1 pt-5 text-xs text-gray-600 cursor-pointer">
<input type="checkbox" id="ar-anonymize" ${arAnonymize ? 'checked' : ''}
class="h-4 w-4 text-blue-600 border-gray-300 rounded">
@@ -173,6 +175,8 @@ export function renderReportsView() {
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.reportsView.exportRevenueCsv()"
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">⬇ Export CSV</button>
<button onclick="window.reportsView.exportRevenuePdf()"
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">📄 Export PDF</button>
<label class="flex items-center gap-1 pt-5 text-xs text-gray-600 cursor-pointer">
<input type="checkbox" id="rev-group-month" ${revGroupByMonth ? 'checked' : ''}
class="h-4 w-4 text-blue-600 border-gray-300 rounded">
@@ -394,6 +398,20 @@ export function exportArAgingCsv() {
downloadCsv(`ar-aging-${arData.asOf}${arData.anonymized ? '-anonymized' : ''}.csv`, lines);
}
/**
* PDF-Export nach dem Muster von exportCustomerRevenuePdf: Parameter direkt
* aus den Eingabefeldern, der Server holt die Daten neu. Der Report muss
* dafür nicht vorher ausgeführt worden sein.
*/
export function exportArAgingPdf() {
const asOfEl = document.getElementById('ar-asof');
if (!asOfEl?.value) return alert('Please select an as-of date first.');
const anonymize = document.getElementById('ar-anonymize')?.checked || false;
let url = `/api/reports/ar-aging/pdf?asOf=${asOfEl.value}`;
if (anonymize) url += '&anonymize=true';
window.open(url, '_blank');
}
// ── Report 2: Invoice-Level Revenue ─────────────────────────────────
export async function loadRevenue() {
@@ -540,6 +558,19 @@ export function exportRevenueCsv() {
downloadCsv(`revenue-${revData.from}_to_${revData.to}${revData.anonymized ? '-anonymized' : ''}.csv`, lines);
}
export function exportRevenuePdf() {
const fromEl = document.getElementById('rev-from');
const toEl = document.getElementById('rev-to');
if (!fromEl?.value || !toEl?.value) return alert('Please select both a from and a to date.');
if (fromEl.value > toEl.value) return alert('The from date must not be after the to date.');
const anonymize = document.getElementById('rev-anonymize')?.checked || false;
const groupByMonth = document.getElementById('rev-group-month')?.checked || false;
let url = `/api/reports/revenue/pdf?from=${fromEl.value}&to=${toEl.value}`;
if (anonymize) url += '&anonymize=true';
if (groupByMonth) url += '&groupByMonth=true';
window.open(url, '_blank');
}
// ────────────────────────────────────────────────────────────────────
// Aus accounting-view.js übernommene Reports
// Funktionskörper 1:1; geändert wurden nur die onclick-Namespaces im
@@ -678,8 +709,10 @@ window.reportsView = {
renderReportsView,
loadArAging,
exportArAgingCsv,
exportArAgingPdf,
loadRevenue,
exportRevenueCsv,
exportRevenuePdf,
loadProfitLoss,
loadBalanceSheet,
loadTaxSummary,

View File

@@ -16,6 +16,7 @@
const express = require('express');
const router = express.Router();
const { pool } = require('../config/database');
const { renderArAgingPdf, renderRevenuePdf } = require('../services/report-pdf-service');
// ────────────────────────────────────────────────────────────────────
// Helpers
@@ -140,14 +141,13 @@ aged AS (
WHERE b.open_amount > 0
)`;
router.get('/ar-aging', async (req, res) => {
try {
const asOf = req.query.asOf || todayISO();
if (!DATE_RE.test(asOf)) {
return res.status(400).json({ error: 'asOf must be a valid date (YYYY-MM-DD)' });
}
const doAnonymize = isTrue(req.query.anonymize);
/**
* Beschafft die AR-Aging-Daten. Bewusst als eigene Funktion, damit der
* JSON-Endpoint und der PDF-Endpoint exakt dieselbe Query, dieselben
* SQL-Summen und dieselbe Anonymisierung verwenden — Bildschirm und PDF
* können so gar nicht auseinanderlaufen.
*/
async function fetchArAging(asOf, doAnonymize) {
const rowsResult = await pool.query(
`${AR_BASE_CTE}
SELECT id, invoice_number, invoice_date, terms, customer_id, customer_name,
@@ -190,7 +190,7 @@ router.get('/ar-aging', async (req, res) => {
? anonymizeRows(rowsResult.rows, await buildPseudonymMap())
: rowsResult.rows;
res.json({
return {
asOf,
anonymized: doAnonymize,
methodology:
@@ -203,7 +203,16 @@ router.get('/ar-aging', async (req, res) => {
buckets,
totals,
rows
});
};
}
router.get('/ar-aging', async (req, res) => {
try {
const asOf = req.query.asOf || todayISO();
if (!DATE_RE.test(asOf)) {
return res.status(400).json({ error: 'asOf must be a valid date (YYYY-MM-DD)' });
}
res.json(await fetchArAging(asOf, isTrue(req.query.anonymize)));
} catch (err) {
console.error('ar-aging error:', err.message);
res.status(500).json({ error: err.message });
@@ -229,21 +238,8 @@ const REVENUE_FROM_SQL = `
) pa ON pa.invoice_id = i.id
WHERE i.invoice_date >= $1::date AND i.invoice_date <= $2::date`;
router.get('/revenue', async (req, res) => {
try {
const { from, to } = req.query;
if (!from || !to) {
return res.status(400).json({ error: 'from and to are required' });
}
if (!DATE_RE.test(from) || !DATE_RE.test(to)) {
return res.status(400).json({ error: 'from and to must be valid dates (YYYY-MM-DD)' });
}
if (from > to) {
return res.status(400).json({ error: 'from must not be after to' });
}
const doAnonymize = isTrue(req.query.anonymize);
const groupByMonth = isTrue(req.query.groupByMonth);
/** Siehe fetchArAging — dieselbe Begründung für die gemeinsame Nutzung. */
async function fetchRevenue(from, to, doAnonymize, groupByMonth) {
const rowsResult = await pool.query(
`SELECT
i.id,
@@ -302,7 +298,7 @@ router.get('/revenue', async (req, res) => {
? anonymizeRows(rowsResult.rows, await buildPseudonymMap())
: rowsResult.rows;
res.json({
return {
from,
to,
anonymized: doAnonymize,
@@ -317,11 +313,70 @@ router.get('/revenue', async (req, res) => {
months,
totals,
rows
});
};
}
/** Gemeinsame Validierung für /revenue und /revenue/pdf. */
function validateRevenueParams(query) {
const { from, to } = query;
if (!from || !to) return { error: 'from and to are required' };
if (!DATE_RE.test(from) || !DATE_RE.test(to)) {
return { error: 'from and to must be valid dates (YYYY-MM-DD)' };
}
if (from > to) return { error: 'from must not be after to' };
return { from, to };
}
router.get('/revenue', async (req, res) => {
try {
const v = validateRevenueParams(req.query);
if (v.error) return res.status(400).json({ error: v.error });
res.json(await fetchRevenue(
v.from, v.to, isTrue(req.query.anonymize), isTrue(req.query.groupByMonth)
));
} catch (err) {
console.error('revenue error:', err.message);
res.status(500).json({ error: err.message });
}
});
// ────────────────────────────────────────────────────────────────────
// PDF-Exporte
//
// Die Daten werden hier serverseitig NEU geholt — die fertigen Zeilen des
// Clients werden bewusst nicht entgegengenommen. Damit lässt sich die
// Anonymisierung nicht clientseitig umgehen; sie hängt allein am
// anonymize-Query-Parameter, den dieselbe fetch-Funktion auswertet wie
// die JSON-Route.
// ────────────────────────────────────────────────────────────────────
router.get('/ar-aging/pdf', async (req, res) => {
try {
const asOf = req.query.asOf || todayISO();
if (!DATE_RE.test(asOf)) {
return res.status(400).json({ error: 'asOf must be a valid date (YYYY-MM-DD)' });
}
const doAnonymize = isTrue(req.query.anonymize);
const data = await fetchArAging(asOf, doAnonymize);
await renderArAgingPdf(res, data);
} catch (err) {
console.error('ar-aging pdf error:', err.message);
res.status(500).json({ error: err.message });
}
});
router.get('/revenue/pdf', async (req, res) => {
try {
const v = validateRevenueParams(req.query);
if (v.error) return res.status(400).json({ error: v.error });
const data = await fetchRevenue(
v.from, v.to, isTrue(req.query.anonymize), isTrue(req.query.groupByMonth)
);
await renderRevenuePdf(res, data);
} catch (err) {
console.error('revenue pdf error:', err.message);
res.status(500).json({ error: err.message });
}
});
module.exports = router;

View File

@@ -0,0 +1,281 @@
/**
* report-pdf-service.js — PDF-Erzeugung für die Due-Diligence-Reports
*
* Nutzt bewusst dieselbe Puppeteer-Instanz wie alle übrigen PDFs: die Engine
* ist generatePdfFromHtml() aus pdf-service.js, das auf dem beim App-Start
* gestarteten Browser arbeitet (src/index.js initBrowser → setBrowser).
* Hier wird keine zweite Engine eingeführt.
*
* Die Methodik-Zeile ist fester Bestandteil des Kopfes — buildReportHtml()
* verlangt sie, damit ein Export sie nicht versehentlich weglassen kann.
*/
const path = require('path');
const fs = require('fs').promises;
const { generatePdfFromHtml, getLogoHtml } = require('./pdf-service');
const { formatMoney } = require('../utils/helpers');
const TEMPLATE_PATH = path.join(__dirname, '..', '..', 'templates', 'dd-report-template.html');
const COMPANY_NAME = 'Bay Area Affiliates, Inc.';
const COMPANY_ADDRESS = '1001 Blucher Street<br>Corpus Christi, Texas 78401';
const SLOGAN = 'Providing IT Services and Support in South Texas Since 1996';
function escapeHtml(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
/** numeric-String/Zahl → "$1,234.56" (formatMoney liefert ohne Dollarzeichen). */
function money(v) {
return `$${formatMoney(v ?? 0)}`;
}
/**
* → MM/DD/YYYY, identisch zu formatDate() in public/js/utils/helpers.js.
*
* Wichtig: Der pg-Treiber gibt date-Spalten als Date-Objekte zurück, nicht als
* Strings. Im JSON-Weg serialisiert res.json() sie vorher nach ISO, hier kommen
* sie roh an — ohne den Date-Zweig landete man bei "Thu Jul 02 2026 00:00:00
* GMT-0500 (…)". Wie im Frontend wird nach UTC gelesen, damit beide Wege für
* denselben Datensatz dasselbe Datum zeigen.
*/
function fmtDate(v) {
if (!v) return '—';
if (v instanceof Date) {
const m = String(v.getUTCMonth() + 1).padStart(2, '0');
const d = String(v.getUTCDate()).padStart(2, '0');
return `${m}/${d}/${v.getUTCFullYear()}`;
}
const parts = String(v).split('T')[0].split('-');
if (parts.length !== 3) return String(v);
return `${parts[1]}/${parts[2]}/${parts[0]}`;
}
/**
* Baut das Report-HTML aus dem gemeinsamen Template.
* @param {object} o
* @param {string} o.title Titel in Großbuchstaben, z.B. "ACCOUNTS RECEIVABLE AGING"
* @param {string} o.meta Zeitraum-/Stichtagszeile
* @param {string} o.methodology Pflichtfeld — landet fest im Kopf
* @param {boolean} o.anonymized hängt " (anonymized)" an den Titel
* @param {string} o.detailTitle Überschrift über der Positionstabelle
* @param {string} o.tableHead <tr>…</tr> für <thead>
* @param {string} o.tableBody <tr>…</tr>-Folge für <tbody>
* @param {string} [o.summaryBlock] optionaler Block oberhalb der Positionen
*/
async function buildReportHtml(o) {
if (!o.methodology) {
throw new Error('buildReportHtml: methodology is required — it must appear in the PDF header');
}
const template = await fs.readFile(TEMPLATE_PATH, 'utf-8');
const logoHTML = await getLogoHtml();
const generated = new Date().toISOString().split('T')[0];
return template
.replace('{{LOGO_HTML}}', logoHTML)
.replace('{{COMPANY_NAME}}', COMPANY_NAME)
.replace('{{COMPANY_ADDRESS}}', COMPANY_ADDRESS)
.replace('{{SLOGAN}}', SLOGAN)
.replace('{{REPORT_TITLE}}', escapeHtml(o.title))
.replace('{{ANONYMIZED_NOTE}}', o.anonymized ? ' (anonymized)' : '')
.replace('{{REPORT_META}}', escapeHtml(o.meta))
.replace('{{METHODOLOGY}}', escapeHtml(o.methodology))
.replace('{{SUMMARY_BLOCK}}', o.summaryBlock || '')
.replace('{{DETAIL_TITLE}}', escapeHtml(o.detailTitle || 'Detail'))
.replace('{{TABLE_HEAD}}', o.tableHead)
.replace('{{REPORT_BODY}}', o.tableBody)
.replace('{{GENERATED_DATE}}', generated);
}
/** Erzeugt das PDF und schickt es als Download — wie renderReportPdf in accounting.js. */
async function sendReportPdf(res, html, filename) {
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');
}
// ────────────────────────────────────────────────────────────────────
// Report 1 — Accounts Receivable Aging
// ────────────────────────────────────────────────────────────────────
const AR_BUCKET_LABELS = [
['current', 'Current'],
['d1_30', '1-30 days'],
['d31_60', '31-60 days'],
['d61_90', '61-90 days'],
['d90_plus', '90+ days']
];
function arSummaryBlock(data) {
const rows = AR_BUCKET_LABELS.map(([key, label]) => {
const b = data.buckets[key];
return `<tr>
<td class="text">${label}</td>
<td class="center">${b.invoice_count}</td>
<td>${money(b.original_amount)}</td>
<td>${money(b.open_amount)}</td>
</tr>`;
}).join('');
const t = data.totals;
return `
<div class="section-title">Aging Summary</div>
<table class="items-table summary-table">
<thead>
<tr><th>Bucket</th><th>Invoices</th><th>Original</th><th>Open</th></tr>
</thead>
<tbody>
${rows}
<tr class="grand-total">
<td class="text">Total</td>
<td class="center">${t.invoice_count}</td>
<td>${money(t.original_amount)}</td>
<td>${money(t.open_amount)}</td>
</tr>
</tbody>
</table>`;
}
async function renderArAgingPdf(res, data) {
const labelOf = Object.fromEntries(AR_BUCKET_LABELS);
let body = data.rows.map(r => `
<tr>
<td class="text">${escapeHtml(r.customer_name || '—')}</td>
<td class="text">${escapeHtml(r.invoice_number || '—')}</td>
<td class="center">${fmtDate(r.invoice_date)}</td>
<td class="center">${fmtDate(r.due_date)}</td>
<td>${money(r.original_amount)}</td>
<td>${money(r.open_amount)}</td>
<td class="center">${labelOf[r.bucket] || r.bucket}</td>
</tr>`).join('');
if (!data.rows.length) {
body = `<tr><td class="text" colspan="7">No open receivables as of ${escapeHtml(data.asOf)}.</td></tr>`;
} else {
const t = data.totals;
body += `
<tr class="grand-total">
<td class="text" colspan="4">TOTAL (${t.invoice_count} invoices)</td>
<td>${money(t.original_amount)}</td>
<td>${money(t.open_amount)}</td>
<td></td>
</tr>`;
}
const html = await buildReportHtml({
title: 'ACCOUNTS RECEIVABLE AGING',
anonymized: data.anonymized,
meta: `As of: ${data.asOf}`,
methodology: data.methodology,
summaryBlock: data.rows.length ? arSummaryBlock(data) : '',
detailTitle: 'Open Invoices',
tableHead: `<tr>
<th>Customer</th><th>Invoice #</th><th>Date</th><th>Due</th>
<th>Original</th><th>Open</th><th>Bucket</th>
</tr>`,
tableBody: body
});
await sendReportPdf(res, html,
`AR-Aging-${data.asOf}${data.anonymized ? '-anonymized' : ''}`);
}
// ────────────────────────────────────────────────────────────────────
// Report 2 — Invoice-Level Revenue
// ────────────────────────────────────────────────────────────────────
function monthLabel(monthKey) {
const [y, m] = monthKey.split('-').map(Number);
return new Date(Date.UTC(y, m - 1, 1))
.toLocaleDateString('en-US', { month: 'long', year: 'numeric', timeZone: 'UTC' });
}
function revenueRow(r) {
return `
<tr>
<td class="text">${escapeHtml(r.customer_name || '—')}</td>
<td class="text">${escapeHtml(r.invoice_number || '—')}</td>
<td class="center">${fmtDate(r.invoice_date)}</td>
<td>${money(r.subtotal)}</td>
<td>${money(r.tax_amount)}</td>
<td>${money(r.total)}</td>
<td>${money(r.paid_amount)}</td>
<td>${money(r.open_amount)}</td>
<td class="center">${escapeHtml(r.payment_status || '—')}</td>
</tr>`;
}
function revenueSummaryRow(label, s, cls) {
return `
<tr class="${cls}">
<td class="text" colspan="3">${escapeHtml(label)} (${s.invoice_count} invoices)</td>
<td>${money(s.subtotal)}</td>
<td>${money(s.tax_amount)}</td>
<td>${money(s.total)}</td>
<td>${money(s.paid_amount)}</td>
<td>${money(s.open_amount)}</td>
<td></td>
</tr>`;
}
async function renderRevenuePdf(res, data) {
let body = '';
if (!data.rows.length) {
body = `<tr><td class="text" colspan="9">No invoices in this period.</td></tr>`;
} else if (data.groupByMonth) {
// Monats-Zwischensummen stammen aus SQL, nicht aus JS-Addition
const sumsByMonth = new Map(data.months.map(m => [m.month_key, m]));
let currentMonth = null;
for (const r of data.rows) {
if (r.month_key !== currentMonth) {
if (currentMonth !== null) {
body += revenueSummaryRow('Group Total', sumsByMonth.get(currentMonth), 'group-total');
}
currentMonth = r.month_key;
body += `<tr class="group-header"><td colspan="9">${escapeHtml(monthLabel(r.month_key))}</td></tr>`;
}
body += revenueRow(r);
}
if (currentMonth !== null) {
body += revenueSummaryRow('Group Total', sumsByMonth.get(currentMonth), 'group-total');
}
body += revenueSummaryRow('TOTAL', data.totals, 'grand-total');
} else {
body = data.rows.map(revenueRow).join('');
body += revenueSummaryRow('TOTAL', data.totals, 'grand-total');
}
const html = await buildReportHtml({
title: 'INVOICE-LEVEL REVENUE',
anonymized: data.anonymized,
meta: `Period: ${data.from} to ${data.to}`
+ (data.groupByMonth ? ' · grouped by month' : ''),
methodology: data.methodology,
detailTitle: data.groupByMonth ? 'Invoices by Month' : 'Invoices',
tableHead: `<tr>
<th>Customer</th><th>Invoice #</th><th>Date</th>
<th>Subtotal</th><th>Tax</th><th>Total</th>
<th>Paid</th><th>Open</th><th>Status</th>
</tr>`,
tableBody: body
});
await sendReportPdf(res, html,
`Invoice-Revenue-${data.from}-to-${data.to}${data.anonymized ? '-anonymized' : ''}`);
}
module.exports = {
buildReportHtml,
sendReportPdf,
renderArAgingPdf,
renderRevenuePdf
};

View File

@@ -0,0 +1,77 @@
<!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: 10px; }
.report-meta p { font-size: 13px; color: #555; }
.methodology { font-size: 11px; color: #444; background-color: #f5f5f5; border: 1px solid #ccc; padding: 8px 10px; margin-bottom: 20px; line-height: 1.45; }
.methodology strong { color: #222; }
.section-title { font-size: 14px; font-weight: bold; margin: 18px 0 6px; }
.items-table { width: 100%; border-collapse: collapse; margin: 10px 0 20px; font-size: 11px; }
.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: 5px 8px; text-align: right; }
.items-table td:first-child { text-align: left; }
.items-table td.text { text-align: left; }
.items-table td.center { text-align: center; }
.items-table tr.group-header td { background-color: #eef2f7; font-weight: bold; text-align: left; }
.items-table tr.group-total td { background-color: #f5f5f5; font-weight: bold; }
.items-table tr.grand-total td { background-color: #e8e8e8; font-weight: bold; border-top: 2px solid #000; }
.summary-table { width: 55%; }
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}}{{ANONYMIZED_NOTE}}</div>
<div class="report-meta">
<p>{{REPORT_META}}</p>
</div>
<div class="methodology"><strong>Methodology:</strong> {{METHODOLOGY}}</div>
{{SUMMARY_BLOCK}}
<div class="section-title">{{DETAIL_TITLE}}</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>