${reportCard('Accounts Receivable Aging', arAgingBody)}
${reportCard('Invoice-Level Revenue', revenueBody)}
+ ${reportCard('Revenue by Category', categoryRevenueBody)}
${reportCard('Customer Revenue (Summary)', customerRevenueBody)}
${reportCard('Profit & Loss', profitLossBody)}
${reportCard('Balance Sheet', balanceSheetBody)}
@@ -571,6 +605,162 @@ export function exportRevenuePdf() {
window.open(url, '_blank');
}
+// ── Report 3: Revenue by Category ───────────────────────────────────
+
+const MONTH_ABBR = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
+ 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
+
+/** 'YYYY-MM' → { m: 'Mar', y: '2026' } — reine String-Zerlegung, kein Date. */
+function monthParts(monthKey) {
+ return { m: MONTH_ABBR[Number(monthKey.slice(5, 7)) - 1], y: monthKey.slice(0, 4) };
+}
+
+/**
+ * Spaltenmodell der Matrix: Monate in Jahresblöcken, je Block optional eine
+ * Jahressumme, dahinter Gesamt und Prozentanteil. Identisch zu
+ * categoryColumns() in report-pdf-service.js — Bildschirm, CSV und PDF zeigen
+ * damit dieselben Spalten in derselben Reihenfolge.
+ */
+function categoryColumns(data) {
+ const cols = [];
+ if (data.groupByMonth) {
+ for (const y of data.years) {
+ for (const m of y.months) cols.push({ type: 'month', key: m });
+ if (data.showYearColumns) cols.push({ type: 'year', key: y.year });
+ }
+ }
+ cols.push({ type: 'total' });
+ cols.push({ type: 'pct' });
+ return cols;
+}
+
+/** Wert einer Kategoriezeile in einer Spalte; null = Prozentspalte. */
+function categoryCell(data, catKey, col) {
+ if (col.type === 'month') return data.cells[catKey][col.key];
+ if (col.type === 'year') return data.yearCells[catKey][col.key];
+ if (col.type === 'total') return data.categoryTotals[catKey].revenue;
+ return null;
+}
+
+/** Wert der TOTAL-Zeile in einer Spalte. */
+function categoryTotalCell(data, col) {
+ if (col.type === 'month') return data.monthTotals[col.key];
+ if (col.type === 'year') return data.yearTotals[col.key];
+ if (col.type === 'total') return data.grandTotal;
+ return null;
+}
+
+/** Spaltenüberschrift für Bildschirm und CSV (dort einzeilig). */
+function categoryColumnLabel(col, oneLine) {
+ if (col.type === 'month') {
+ const p = monthParts(col.key);
+ return oneLine ? `${p.m} ${p.y}` : `${p.m}
${p.y}`;
+ }
+ if (col.type === 'year') {
+ return oneLine ? `${col.key} Total`
+ : `${col.key}
Total`;
+ }
+ if (col.type === 'total') return 'Total';
+ return oneLine ? '% of Total' : '% of
Total';
+}
+
+export async function loadCategoryRevenue() {
+ catFrom = document.getElementById('cat-from').value;
+ catTo = document.getElementById('cat-to').value;
+ catGroupByMonth = document.getElementById('cat-group-month')?.checked || false;
+
+ if (!catFrom || !catTo) return reportError('cat-result', 'Please select both a from and a to date.');
+ if (catFrom > catTo) return reportError('cat-result', 'The from date must not be after the to date.');
+
+ showLoading('cat-result', 'Loading category revenue…');
+ try {
+ const data = await window.API.reports.getCategoryRevenue(catFrom, catTo, catGroupByMonth);
+ if (data.error) return reportError('cat-result', data.error);
+ catData = data;
+ renderCategoryRevenue(data);
+ } catch (err) {
+ reportError('cat-result', err.message || 'Failed to load category revenue');
+ }
+}
+
+function renderCategoryRevenue(data) {
+ if (!data.hasData) {
+ document.getElementById('cat-result').innerHTML =
+ methodologyBox(data.methodology, false) +
+ '
No invoices found in this period.
';
+ return;
+ }
+
+ const cols = categoryColumns(data);
+ const colClass = c => c.type === 'year' ? ' bg-blue-50 font-semibold'
+ : c.type === 'total' ? ' bg-gray-100 font-semibold' : '';
+
+ const headHtml = `
Category | ` +
+ cols.map(c => `
${categoryColumnLabel(c, false)} | `).join('');
+
+ let rowsHtml = '';
+ for (const cat of data.categories) {
+ const cells = cols.map(c => c.type === 'pct'
+ ? `
${data.categoryTotals[cat.key].pct}% | `
+ : `
${fmtMoney(categoryCell(data, cat.key, c))} | `
+ ).join('');
+ rowsHtml += `
+ | ${escapeHtml(cat.label)} | ${cells}
`;
+ }
+
+ // 100.0 % per Definition (Gesamt/Gesamt), nicht als Summe der gerundeten
+ // Kategorieanteile.
+ const totalCells = cols.map(c => c.type === 'pct'
+ ? `
100.0% | `
+ : `
${fmtMoney(categoryTotalCell(data, c))} | `
+ ).join('');
+ rowsHtml += `
+ | TOTAL (${data.invoiceCount} invoices) | ${totalCells}
`;
+
+ document.getElementById('cat-result').innerHTML =
+ methodologyBox(data.methodology, false) + `
+
+
+ ${headHtml}
+ ${rowsHtml}
+
+
`;
+}
+
+export function exportCategoryRevenueCsv() {
+ if (!catData || !catData.hasData) {
+ return reportError('cat-result', 'Run the report first — there is nothing to export.');
+ }
+ const data = catData;
+ const cols = categoryColumns(data);
+ const lines = [];
+ lines.push(csvRow([`Revenue by Category ${data.from} to ${data.to}`]));
+ lines.push(csvRow([`Methodology: ${data.methodology}`]));
+ lines.push('');
+ lines.push(csvRow(['Category', ...cols.map(c => categoryColumnLabel(c, true))]));
+
+ for (const cat of data.categories) {
+ lines.push(csvRow([cat.label, ...cols.map(c => c.type === 'pct'
+ ? data.categoryTotals[cat.key].pct
+ : categoryCell(data, cat.key, c))]));
+ }
+ lines.push(csvRow([`TOTAL (${data.invoiceCount} invoices)`,
+ ...cols.map(c => c.type === 'pct' ? '100.0' : categoryTotalCell(data, c))]));
+
+ downloadCsv(`category-revenue-${data.from}_to_${data.to}.csv`, lines);
+}
+
+export function exportCategoryRevenuePdf() {
+ const fromEl = document.getElementById('cat-from');
+ const toEl = document.getElementById('cat-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 groupByMonth = document.getElementById('cat-group-month')?.checked || false;
+ let url = `/api/reports/category-revenue/pdf?from=${fromEl.value}&to=${toEl.value}`;
+ 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
@@ -713,6 +903,9 @@ window.reportsView = {
loadRevenue,
exportRevenueCsv,
exportRevenuePdf,
+ loadCategoryRevenue,
+ exportCategoryRevenueCsv,
+ exportCategoryRevenuePdf,
loadProfitLoss,
loadBalanceSheet,
loadTaxSummary,
diff --git a/src/routes/reports.js b/src/routes/reports.js
index c26f38f..5a1bb8b 100644
--- a/src/routes/reports.js
+++ b/src/routes/reports.js
@@ -4,6 +4,7 @@
* Due-Diligence-Reports:
* GET /api/reports/ar-aging?asOf=YYYY-MM-DD&anonymize=true|false
* GET /api/reports/revenue?from=YYYY-MM-DD&to=YYYY-MM-DD&anonymize=true|false&groupByMonth=true|false
+ * GET /api/reports/category-revenue?from=YYYY-MM-DD&to=YYYY-MM-DD&groupByMonth=true|false
*
* Anonymisierung läuft ausschließlich serverseitig: bei anonymize=true verlassen
* weder Kundenname noch customer_id oder bill_to_name den Server, damit auch ein
@@ -16,7 +17,8 @@
const express = require('express');
const router = express.Router();
const { pool } = require('../config/database');
-const { renderArAgingPdf, renderRevenuePdf } = require('../services/report-pdf-service');
+const { renderArAgingPdf, renderRevenuePdf, renderCategoryRevenuePdf } = require('../services/report-pdf-service');
+const { QBO_LABOR_ID, QBO_PARTS_ID, QBO_SUBSCRIPTION_ID } = require('../services/qbo-service');
// ────────────────────────────────────────────────────────────────────
// Helpers
@@ -340,6 +342,209 @@ router.get('/revenue', async (req, res) => {
}
});
+// ────────────────────────────────────────────────────────────────────
+// Report 3 — Revenue by Category (Parts / Labor / Subscription)
+// ────────────────────────────────────────────────────────────────────
+
+/**
+ * Die Kategorie einer Rechnungsposition steckt in invoice_items.qbo_item_id.
+ * Die IDs kommen aus qbo-service, damit es genau eine Quelle der Wahrheit gibt
+ * (dieselben Konstanten benutzt der QBO-Export).
+ *
+ * Alles, was keiner der drei bekannten IDs entspricht, läuft unter 'other'.
+ * Im Produktivbestand kommt das nicht vor — die Zeile erscheint im Report auch
+ * nur, wenn sie ungleich 0 ist, verschluckt aber im Fehlerfall keinen Umsatz.
+ */
+const REVENUE_CATEGORIES = [
+ { key: 'parts', label: 'Parts', itemId: QBO_PARTS_ID },
+ { key: 'labor', label: 'Labor', itemId: QBO_LABOR_ID },
+ { key: 'subscription', label: 'Subscription', itemId: QBO_SUBSCRIPTION_ID }
+];
+const OTHER_CATEGORY = { key: 'other', label: 'Other' };
+
+const CATEGORY_KEY_SQL = `
+ CASE btrim(COALESCE(ii.qbo_item_id, ''))
+ WHEN '${QBO_PARTS_ID}' THEN 'parts'
+ WHEN '${QBO_LABOR_ID}' THEN 'labor'
+ WHEN '${QBO_SUBSCRIPTION_ID}' THEN 'subscription'
+ ELSE 'other'
+ END`;
+
+/**
+ * $1 = from, $2 = to (auf invoice_date) — exakt derselbe Rechnungsfilter wie im
+ * Invoice-Level-Report: kein Status-, kein Zahlungsfilter.
+ *
+ * Bemessungsgrundlage sind die Positionsbeträge, also der NETTO-Umsatz ohne
+ * Sales Tax. Steuer ist keiner Kategorie zurechenbar. Die Summe über alle
+ * Kategorien entspricht damit der Subtotal-Summe des Invoice-Level-Reports.
+ *
+ * Ein GROUPING-SETS-Lauf liefert jede benötigte Ebene: Zelle (Kategorie×Monat),
+ * Jahresspalte (Kategorie×Jahr), Kategoriesumme, Monatssumme, Jahressumme und
+ * Gesamtsumme. month_key/year_key/category_key sind nie NULL (invoice_date ist
+ * NOT NULL, das CASE hat einen ELSE-Zweig) — NULL bedeutet hier also
+ * eindeutig "über diese Dimension aggregiert" und ist kein Datenwert.
+ *
+ * Der Prozentanteil wird ebenfalls in SQL auf numeric gerechnet, nicht in JS.
+ */
+const CATEGORY_REVENUE_SQL = `
+WITH lines AS (
+ SELECT
+ to_char(i.invoice_date, 'YYYY-MM') AS month_key,
+ to_char(i.invoice_date, 'YYYY') AS year_key,
+ ${CATEGORY_KEY_SQL} AS category_key,
+ COALESCE(NULLIF(btrim(ii.amount), '')::numeric, 0) AS amount,
+ i.id AS invoice_id
+ FROM invoices i
+ JOIN invoice_items ii ON ii.invoice_id = i.id
+ WHERE i.invoice_date >= $1::date AND i.invoice_date <= $2::date
+),
+grouped AS (
+ SELECT
+ category_key,
+ month_key,
+ year_key,
+ -- COALESCE: bei leerem Zeitraum liefert die ()-Gruppe sonst NULL statt 0.00
+ COALESCE(SUM(amount), 0)::numeric(12,2) AS revenue,
+ COUNT(DISTINCT invoice_id)::integer AS invoice_count
+ FROM lines
+ GROUP BY GROUPING SETS (
+ (category_key, month_key),
+ (category_key, year_key),
+ (category_key),
+ (month_key),
+ (year_key),
+ ()
+ )
+),
+grand AS (
+ SELECT COALESCE(SUM(amount), 0)::numeric(12,2) AS total FROM lines
+)
+SELECT
+ g.category_key,
+ g.month_key,
+ g.year_key,
+ g.revenue,
+ g.invoice_count,
+ CASE WHEN grand.total > 0
+ THEN round(100 * g.revenue / grand.total, 1)
+ ELSE 0 END::numeric(5,1) AS pct
+FROM grouped g CROSS JOIN grand`;
+
+/**
+ * Monatsspalten werden aus dem gewählten Zeitraum abgeleitet, nicht aus den
+ * gefundenen Daten: ein umsatzloser Monat soll als 0.00-Spalte sichtbar sein und
+ * nicht stillschweigend fehlen. Reine String-Arithmetik auf 'YYYY-MM' — kein
+ * Date-Objekt, damit keine Zeitzone das Ergebnis verschieben kann.
+ */
+function monthKeysInRange(from, to) {
+ const keys = [];
+ let y = Number(from.slice(0, 4));
+ let m = Number(from.slice(5, 7));
+ const lastY = Number(to.slice(0, 4));
+ const lastM = Number(to.slice(5, 7));
+ while (y < lastY || (y === lastY && m <= lastM)) {
+ keys.push(`${y}-${String(m).padStart(2, '0')}`);
+ if (++m > 12) { m = 1; y++; }
+ }
+ return keys;
+}
+
+/** Siehe fetchArAging — dieselbe Begründung für die gemeinsame Nutzung. */
+async function fetchCategoryRevenue(from, to, groupByMonth) {
+ const result = await pool.query(CATEGORY_REVENUE_SQL, [from, to]);
+
+ const cells = {}; // category → month_key → revenue
+ const yearCells = {}; // category → year_key → revenue
+ const categoryTotals = {}; // category → { revenue, pct }
+ const monthTotals = {}; // month_key → revenue
+ const yearTotals = {}; // year_key → revenue
+ let grandTotal = '0.00';
+ let invoiceCount = 0;
+ const seenCategories = new Set();
+
+ for (const r of result.rows) {
+ if (r.category_key) {
+ seenCategories.add(r.category_key);
+ if (r.month_key) {
+ (cells[r.category_key] ||= {})[r.month_key] = r.revenue;
+ } else if (r.year_key) {
+ (yearCells[r.category_key] ||= {})[r.year_key] = r.revenue;
+ } else {
+ categoryTotals[r.category_key] = { revenue: r.revenue, pct: r.pct };
+ }
+ } else if (r.month_key) {
+ monthTotals[r.month_key] = r.revenue;
+ } else if (r.year_key) {
+ yearTotals[r.year_key] = r.revenue;
+ } else {
+ grandTotal = r.revenue;
+ invoiceCount = r.invoice_count;
+ }
+ }
+
+ const months = monthKeysInRange(from, to);
+ const years = [];
+ for (const key of months) {
+ const y = key.slice(0, 4);
+ const entry = years.find(e => e.year === y);
+ if (entry) entry.months.push(key);
+ else years.push({ year: y, months: [key] });
+ }
+
+ // 'Other' nur führen, wenn dort tatsächlich Umsatz gelandet ist.
+ // Die QBO-Item-IDs bleiben serverseitig; der Client braucht nur Key + Label.
+ const categories = REVENUE_CATEGORIES.map(c => ({ key: c.key, label: c.label }));
+ if (seenCategories.has(OTHER_CATEGORY.key)) categories.push({ ...OTHER_CATEGORY });
+
+ // Lücken auffüllen: HTML, CSV und PDF lesen danach dieselbe dichte Matrix
+ // und müssen nicht jeder für sich auf fehlende Zellen prüfen.
+ for (const c of categories) {
+ cells[c.key] ||= {};
+ yearCells[c.key] ||= {};
+ categoryTotals[c.key] ||= { revenue: '0.00', pct: '0.0' };
+ for (const m of months) cells[c.key][m] ||= '0.00';
+ for (const y of years) yearCells[c.key][y.year] ||= '0.00';
+ }
+ for (const m of months) monthTotals[m] ||= '0.00';
+ for (const y of years) yearTotals[y.year] ||= '0.00';
+
+ return {
+ from,
+ to,
+ groupByMonth,
+ // Bei einem Zeitraum innerhalb eines Jahres wäre die Jahresspalte eine
+ // Kopie der Gesamtspalte — dann bleibt sie weg.
+ showYearColumns: groupByMonth && years.length > 1,
+ methodology:
+ `Period filtered on invoice_date from ${from} to ${to} — same invoice selection as the ` +
+ `Invoice-Level Revenue report (no payment or status filter). Revenue is the net line-item ` +
+ `amount excluding sales tax, categorized by line item type; category totals therefore add ` +
+ `up to the invoice subtotals of the period. Percentages are shares of the period's total revenue.`,
+ categories,
+ months,
+ years,
+ cells,
+ yearCells,
+ categoryTotals,
+ monthTotals,
+ yearTotals,
+ grandTotal,
+ invoiceCount,
+ hasData: invoiceCount > 0
+ };
+}
+
+router.get('/category-revenue', async (req, res) => {
+ try {
+ const v = validateRevenueParams(req.query);
+ if (v.error) return res.status(400).json({ error: v.error });
+ res.json(await fetchCategoryRevenue(v.from, v.to, isTrue(req.query.groupByMonth)));
+ } catch (err) {
+ console.error('category-revenue error:', err.message);
+ res.status(500).json({ error: err.message });
+ }
+});
+
// ────────────────────────────────────────────────────────────────────
// PDF-Exporte
//
@@ -379,4 +584,16 @@ router.get('/revenue/pdf', async (req, res) => {
}
});
+router.get('/category-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 fetchCategoryRevenue(v.from, v.to, isTrue(req.query.groupByMonth));
+ await renderCategoryRevenuePdf(res, data);
+ } catch (err) {
+ console.error('category-revenue pdf error:', err.message);
+ res.status(500).json({ error: err.message });
+ }
+});
+
module.exports = router;
diff --git a/src/services/pdf-service.js b/src/services/pdf-service.js
index ae23dd2..29a9cb9 100644
--- a/src/services/pdf-service.js
+++ b/src/services/pdf-service.js
@@ -24,7 +24,8 @@ async function generatePdfFromHtml(html, options = {}) {
const {
format = 'Letter',
margin = { top: '0.5in', right: '0.5in', bottom: '0.5in', left: '0.5in' },
- printBackground = true
+ printBackground = true,
+ landscape = false
} = options;
const browser = await getBrowser();
@@ -42,7 +43,8 @@ async function generatePdfFromHtml(html, options = {}) {
const pdf = await page.pdf({
format,
printBackground,
- margin
+ margin,
+ landscape
});
return pdf;
diff --git a/src/services/report-pdf-service.js b/src/services/report-pdf-service.js
index 0e6da9d..52cde97 100644
--- a/src/services/report-pdf-service.js
+++ b/src/services/report-pdf-service.js
@@ -64,6 +64,9 @@ function fmtDate(v) {
* @param {string} o.tableHead
…
für
* @param {string} o.tableBody …
-Folge für
* @param {string} [o.summaryBlock] optionaler Block oberhalb der Positionen
+ * @param {string} [o.extraStyles] report-spezifische CSS-Regeln, die die
+ * Template-Defaults überschreiben (z.B. für
+ * sehr breite Tabellen)
*/
async function buildReportHtml(o) {
if (!o.methodology) {
@@ -82,6 +85,7 @@ async function buildReportHtml(o) {
.replace('{{ANONYMIZED_NOTE}}', o.anonymized ? ' (anonymized)' : '')
.replace('{{REPORT_META}}', escapeHtml(o.meta))
.replace('{{METHODOLOGY}}', escapeHtml(o.methodology))
+ .replace('{{EXTRA_STYLES}}', o.extraStyles || '')
.replace('{{SUMMARY_BLOCK}}', o.summaryBlock || '')
.replace('{{DETAIL_TITLE}}', escapeHtml(o.detailTitle || 'Detail'))
.replace('{{TABLE_HEAD}}', o.tableHead)
@@ -90,8 +94,8 @@ async function buildReportHtml(o) {
}
/** Erzeugt das PDF und schickt es als Download — wie renderReportPdf in accounting.js. */
-async function sendReportPdf(res, html, filename) {
- const pdf = await generatePdfFromHtml(html);
+async function sendReportPdf(res, html, filename, pdfOptions = {}) {
+ const pdf = await generatePdfFromHtml(html, pdfOptions);
const sanitized = filename.replace(/[^a-zA-Z0-9._-]/g, '-');
res.set({
'Content-Type': 'application/pdf',
@@ -273,9 +277,129 @@ async function renderRevenuePdf(res, data) {
`Invoice-Revenue-${data.from}-to-${data.to}${data.anonymized ? '-anonymized' : ''}`);
}
+// ────────────────────────────────────────────────────────────────────
+// Report 3 — Revenue by Category
+// ────────────────────────────────────────────────────────────────────
+
+const MONTH_ABBR = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
+ 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
+
+/** 'YYYY-MM' → { m: 'Mar', y: '2026' } — reine String-Zerlegung, kein Date. */
+function monthParts(monthKey) {
+ return { m: MONTH_ABBR[Number(monthKey.slice(5, 7)) - 1], y: monthKey.slice(0, 4) };
+}
+
+/**
+ * Spaltenmodell der Matrix: Monate in Jahresblöcken, je Block optional eine
+ * Jahressumme, dahinter Gesamt und Prozentanteil. Dieselbe Reihenfolge benutzt
+ * die Bildschirmansicht in reports-view.js — Screen, CSV und PDF zeigen so
+ * zwingend dieselben Spalten.
+ */
+function categoryColumns(data) {
+ const cols = [];
+ if (data.groupByMonth) {
+ for (const y of data.years) {
+ for (const m of y.months) cols.push({ type: 'month', key: m });
+ if (data.showYearColumns) cols.push({ type: 'year', key: y.year });
+ }
+ }
+ cols.push({ type: 'total' });
+ cols.push({ type: 'pct' });
+ return cols;
+}
+
+/** Wert einer Kategoriezeile in einer Spalte; null = Prozentspalte. */
+function categoryCell(data, catKey, col) {
+ if (col.type === 'month') return data.cells[catKey][col.key];
+ if (col.type === 'year') return data.yearCells[catKey][col.key];
+ if (col.type === 'total') return data.categoryTotals[catKey].revenue;
+ return null;
+}
+
+/** Wert der TOTAL-Zeile in einer Spalte. */
+function categoryTotalCell(data, col) {
+ if (col.type === 'month') return data.monthTotals[col.key];
+ if (col.type === 'year') return data.yearTotals[col.key];
+ if (col.type === 'total') return data.grandTotal;
+ return null;
+}
+
+async function renderCategoryRevenuePdf(res, data) {
+ const cols = categoryColumns(data);
+ const colCount = cols.length + 1; // + Kategoriespalte
+
+ const head = '| Category | ' + cols.map(c => {
+ if (c.type === 'month') {
+ const p = monthParts(c.key);
+ return `${p.m} ${p.y} | `;
+ }
+ if (c.type === 'year') return `${escapeHtml(c.key)} Total | `;
+ if (c.type === 'total') return 'Total | ';
+ return '% of Total | ';
+ }).join('') + '
';
+
+ let body;
+ if (!data.hasData) {
+ body = `| No invoices in this period. |
`;
+ } else {
+ body = data.categories.map(cat => {
+ const cells = cols.map(c => {
+ if (c.type === 'pct') return `${data.categoryTotals[cat.key].pct}% | `;
+ const cls = c.type === 'year' ? ' class="year-col"'
+ : c.type === 'total' ? ' class="total-col"' : '';
+ return `${money(categoryCell(data, cat.key, c))} | `;
+ }).join('');
+ return `| ${escapeHtml(cat.label)} | ${cells}
`;
+ }).join('');
+
+ // Die 100.0 % stehen hier per Definition (Gesamt/Gesamt) und werden
+ // bewusst nicht aus den gerundeten Kategorieanteilen aufaddiert.
+ const totalCells = cols.map(c => {
+ if (c.type === 'pct') return '100.0% | ';
+ const cls = c.type === 'year' ? ' class="year-col"'
+ : c.type === 'total' ? ' class="total-col"' : '';
+ return `${money(categoryTotalCell(data, c))} | `;
+ }).join('');
+ body += `| TOTAL (${data.invoiceCount} invoices) | ${totalCells}
`;
+ }
+
+ // Breite Matrizen: Schrift und Innenabstand schrumpfen mit der Spaltenzahl,
+ // ab acht Spalten wird quer gedruckt. Ein Zeitraum von mehr als etwa zwei
+ // Jahren nach Monaten wird auch damit eng — das ist die praktische Grenze
+ // eines Letter-Blattes, nicht des Reports.
+ const fontSize = colCount <= 10 ? 10 : colCount <= 16 ? 8 : colCount <= 24 ? 7 : 6;
+ const extraStyles = `
+ .container { max-width: none; }
+ .items-table { font-size: ${fontSize}px; ${data.groupByMonth ? '' : 'width: 60%;'} }
+ .items-table th, .items-table td { padding: 3px 4px; }
+ .items-table th { text-align: right; vertical-align: bottom; }
+ .items-table th .yr { font-weight: normal; font-size: 0.85em; color: #555; }
+ .items-table td.cat { font-weight: bold; white-space: nowrap; }
+ .items-table td.pct, .items-table th:last-child { text-align: right; }
+ .items-table th.year-col, .items-table td.year-col { background-color: #eef2f7; font-weight: bold; }
+ .items-table th.total-col, .items-table td.total-col { background-color: #e8e8e8; font-weight: bold; }`;
+
+ const html = await buildReportHtml({
+ title: 'REVENUE BY CATEGORY',
+ anonymized: false,
+ meta: `Period: ${data.from} to ${data.to}`
+ + (data.groupByMonth ? ' · by month' : ''),
+ methodology: data.methodology,
+ detailTitle: data.groupByMonth ? 'Revenue by Category and Month' : 'Revenue by Category',
+ tableHead: head,
+ tableBody: body,
+ extraStyles
+ });
+
+ await sendReportPdf(res, html,
+ `Category-Revenue-${data.from}-to-${data.to}`,
+ { landscape: colCount >= 8 });
+}
+
module.exports = {
buildReportHtml,
sendReportPdf,
renderArAgingPdf,
- renderRevenuePdf
+ renderRevenuePdf,
+ renderCategoryRevenuePdf
};
diff --git a/templates/dd-report-template.html b/templates/dd-report-template.html
index f1dc8cb..7eb5634 100644
--- a/templates/dd-report-template.html
+++ b/templates/dd-report-template.html
@@ -30,6 +30,7 @@
.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; }
+ {{EXTRA_STYLES}}