diff --git a/public/js/utils/api.js b/public/js/utils/api.js index 71ba24b..f588e64 100644 --- a/public/js/utils/api.js +++ b/public/js/utils/api.js @@ -235,6 +235,11 @@ const API = { if (anonymize) params.set('anonymize', 'true'); if (groupByMonth) params.set('groupByMonth', 'true'); return fetch('/api/reports/revenue?' + params.toString()).then(r => r.json()); + }, + getCategoryRevenue: (from, to, groupByMonth = false) => { + const params = new URLSearchParams({ from, to }); + if (groupByMonth) params.set('groupByMonth', 'true'); + return fetch('/api/reports/category-revenue?' + params.toString()).then(r => r.json()); } }, diff --git a/public/js/views/reports-view.js b/public/js/views/reports-view.js index 0360c82..0bb058f 100644 --- a/public/js/views/reports-view.js +++ b/public/js/views/reports-view.js @@ -3,10 +3,12 @@ * * 1. Accounts Receivable Aging (Stichtag frei wählbar) * 2. Invoice-Level Revenue (Zeitraum frei wählbar, optional nach Monat gruppiert) + * 3. Revenue by Category (Parts/Labor/Subscription, optional Monate als Spalten) * - * Beide Reports können Kundennamen pseudonymisieren. Die Anonymisierung passiert - * ausschließlich serverseitig — dieses Modul bekommt bei aktiver Option gar keine - * Klarnamen zu sehen und kann sie folglich auch nicht in ein CSV schreiben. + * Die ersten beiden Reports können Kundennamen pseudonymisieren. Die Anonymisierung + * passiert ausschließlich serverseitig — dieses Modul bekommt bei aktiver Option gar + * keine Klarnamen zu sehen und kann sie folglich auch nicht in ein CSV schreiben. + * Report 3 aggregiert über Kategorien und enthält gar keine Kundennamen. * * Beträge kommen als numeric-Strings vom Server und werden nur zur Anzeige * formatiert. Ins CSV gehen die Rohwerte, damit Excel sauber rechnen kann. @@ -34,6 +36,11 @@ let revAnonymize = false; let revGroupByMonth = false; let revData = null; +let catFrom = startOfYear; +let catTo = today; +let catGroupByMonth = false; +let catData = null; + // State der aus accounting-view.js übernommenen Reports (1:1) let plStartDate = null; let plEndDate = null; @@ -190,6 +197,32 @@ export function renderReportsView() {
`; + const categoryRevenueBody = ` +
+
+ + +
+
+ + +
+ + + + +
+
`; + // ── Ab hier 1:1 aus accounting-view.js injectReportsControls() ── const profitLossBody = `
@@ -254,6 +287,7 @@ export function renderReportsView() {
${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}}