category report
This commit is contained in:
@@ -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 & 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,
|
||||
|
||||
Reference in New Issue
Block a user