category report

This commit is contained in:
2026-08-19 16:48:01 +02:00
parent c63e1070e0
commit f8b0d52ff7
6 changed files with 551 additions and 9 deletions

View File

@@ -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());
}
},

View File

@@ -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() {
</div>
<div id="rev-result"></div>`;
const categoryRevenueBody = `
<div class="flex flex-wrap items-end gap-3 mb-3">
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">From</label>
<input type="date" id="cat-from" value="${catFrom}"
class="px-3 py-1.5 border border-gray-300 rounded-md text-sm">
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">To</label>
<input type="date" id="cat-to" value="${catTo}"
class="px-3 py-1.5 border border-gray-300 rounded-md text-sm">
</div>
<button onclick="window.reportsView.loadCategoryRevenue()"
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.exportCategoryRevenueCsv()"
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.exportCategoryRevenuePdf()"
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="cat-group-month" ${catGroupByMonth ? 'checked' : ''}
class="h-4 w-4 text-blue-600 border-gray-300 rounded">
Group by month
</label>
</div>
<div id="cat-result"></div>`;
// ── Ab hier 1:1 aus accounting-view.js injectReportsControls() ──
const profitLossBody = `
<div class="flex flex-wrap items-end gap-3 mb-3">
@@ -254,6 +287,7 @@ export function renderReportsView() {
<div class="space-y-4">
${reportCard('Accounts Receivable Aging', arAgingBody)}
${reportCard('Invoice-Level Revenue', revenueBody)}
${reportCard('Revenue by Category', categoryRevenueBody)}
${reportCard('Customer Revenue (Summary)', customerRevenueBody)}
${reportCard('Profit &amp; 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}<br><span class="font-normal text-gray-500">${p.y}</span>`;
}
if (col.type === 'year') {
return oneLine ? `${col.key} Total`
: `${col.key}<br><span class="font-normal text-gray-500">Total</span>`;
}
if (col.type === 'total') return 'Total';
return oneLine ? '% of Total' : '% of<br><span class="font-normal text-gray-500">Total</span>';
}
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) +
'<p class="text-sm text-gray-500">No invoices found in this period.</p>';
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 = `<th class="px-3 py-2 text-left font-medium text-gray-700">Category</th>` +
cols.map(c => `<th class="px-3 py-2 text-right font-medium text-gray-700 whitespace-nowrap${colClass(c)}">${categoryColumnLabel(c, false)}</th>`).join('');
let rowsHtml = '';
for (const cat of data.categories) {
const cells = cols.map(c => c.type === 'pct'
? `<td class="px-3 py-2 text-sm text-right text-gray-500">${data.categoryTotals[cat.key].pct}%</td>`
: `<td class="px-3 py-2 text-sm text-right whitespace-nowrap${colClass(c)}">${fmtMoney(categoryCell(data, cat.key, c))}</td>`
).join('');
rowsHtml += `<tr class="border-t hover:bg-gray-50">
<td class="px-3 py-2 text-sm font-medium whitespace-nowrap">${escapeHtml(cat.label)}</td>${cells}</tr>`;
}
// 100.0 % per Definition (Gesamt/Gesamt), nicht als Summe der gerundeten
// Kategorieanteile.
const totalCells = cols.map(c => c.type === 'pct'
? `<td class="px-3 py-2 text-sm text-right">100.0%</td>`
: `<td class="px-3 py-2 text-sm text-right whitespace-nowrap${colClass(c)}">${fmtMoney(categoryTotalCell(data, c))}</td>`
).join('');
rowsHtml += `<tr class="border-t-2 border-gray-400 bg-gray-100 font-bold">
<td class="px-3 py-2 text-sm whitespace-nowrap">TOTAL (${data.invoiceCount} invoices)</td>${totalCells}</tr>`;
document.getElementById('cat-result').innerHTML =
methodologyBox(data.methodology, false) + `
<div class="overflow-x-auto border border-gray-200 rounded">
<table class="min-w-full text-sm">
<thead class="bg-gray-50"><tr>${headHtml}</tr></thead>
<tbody>${rowsHtml}</tbody>
</table>
</div>`;
}
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,