pdf export
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
281
src/services/report-pdf-service.js
Normal file
281
src/services/report-pdf-service.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/** 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
|
||||
};
|
||||
77
templates/dd-report-template.html
Normal file
77
templates/dd-report-template.html
Normal 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>
|
||||
Reference in New Issue
Block a user