Files
invoice-system/public/js/views/reports-view.js
2026-08-19 16:48:01 +02:00

918 lines
46 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* reports-view.js — Due-Diligence Reports
*
* 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)
*
* 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.
*/
import '../utils/api.js';
import { formatDate } from '../utils/helpers.js';
import {
fmtMoney, escapeHtml, showError, showLoading,
todayISO, firstOfMonthISO, lastOfMonthISO, prevMonthISO, firstOfYearISO,
renderQboReport, initAlpineTree
} from '../utils/report-helpers.js';
// ── State ───────────────────────────────────────────────────────────
const today = new Date().toISOString().split('T')[0];
const startOfYear = `${new Date().getFullYear()}-01-01`;
let arAsOf = today;
let arAnonymize = false;
let arData = null;
let revFrom = startOfYear;
let revTo = today;
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;
let plAccountingMethod = 'Accrual';
let bsAsOfDate = null;
let bsAccountingMethod = 'Accrual';
let tsMonth = null; // 'YYYY-MM' — selected month for tax summary
let tsAccountingMethod = 'Accrual';
let crStartDate = null;
let crEndDate = null;
const AR_BUCKETS = [
{ key: 'current', label: 'Current' },
{ key: 'd1_30', label: '130 days' },
{ key: 'd31_60', label: '3160 days' },
{ key: 'd61_90', label: '6190 days' },
{ key: 'd90_plus', label: '90+ days' }
];
const BUCKET_LABEL = Object.fromEntries(AR_BUCKETS.map(b => [b.key, b.label]));
// ── Local helpers ───────────────────────────────────────────────────
// fmtMoney/escapeHtml/showError/showLoading kommen aus report-helpers.js.
/** showError mit dem Reports-spezifischen Titel. */
function reportError(slotId, message) {
showError(slotId, message, 'Report Error');
}
function methodologyBox(text, anonymized) {
return `
<div class="mb-3 px-3 py-2 bg-gray-50 border border-gray-200 rounded text-xs text-gray-600">
<span class="font-semibold text-gray-700">Methodology:</span> ${escapeHtml(text)}
${anonymized ? '<span class="ml-2 inline-block px-2 py-0.5 bg-amber-100 text-amber-800 rounded font-medium">anonymized</span>' : ''}
</div>`;
}
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' });
}
// ── CSV ─────────────────────────────────────────────────────────────
function csvCell(v) {
if (v == null) return '';
const s = String(v);
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
function csvRow(cells) {
return cells.map(csvCell).join(',');
}
function downloadCsv(filename, lines) {
// BOM, damit Excel UTF-8 korrekt erkennt
const blob = new Blob(['' + lines.join('\r\n')], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// ── Shell ───────────────────────────────────────────────────────────
function reportCard(title, bodyHtml) {
// x-show statt x-if: der Teilbaum bleibt im DOM, damit Datumsfelder und
// Checkboxen ihren Zustand über das Zu-/Aufklappen hinweg behalten.
// Das inline display:none verhindert ein Aufblitzen, bis Alpine initialisiert.
return `
<div class="bg-white rounded-lg shadow-sm border border-gray-200" x-data="{ open: false }">
<div class="px-4 py-3 border-b bg-gray-50 flex items-center gap-2 cursor-pointer select-none"
@click="open = !open">
<svg class="w-4 h-4 text-gray-500 transition-transform" :class="open ? 'rotate-90' : ''"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<h3 class="font-semibold text-gray-800">${title}</h3>
</div>
<div class="p-4" x-show="open" style="display:none">
${bodyHtml}
</div>
</div>`;
}
export function renderReportsView() {
const el = document.getElementById('reports-content');
if (!el) return;
// Defaults der übernommenen Reports — 1:1 aus injectReportsControls()
if (!plStartDate) plStartDate = firstOfYearISO();
if (!plEndDate) plEndDate = todayISO();
if (!bsAsOfDate) bsAsOfDate = todayISO();
if (!tsMonth) tsMonth = prevMonthISO();
if (!crStartDate) crStartDate = firstOfYearISO();
if (!crEndDate) crEndDate = todayISO();
const arAgingBody = `
<div class="flex flex-wrap items-end gap-3 mb-3">
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">As of</label>
<input type="date" id="ar-asof" value="${arAsOf}"
class="px-3 py-1.5 border border-gray-300 rounded-md text-sm">
</div>
<button onclick="window.reportsView.loadArAging()"
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">
Anonymize customers
</label>
</div>
<div id="ar-result"></div>`;
const revenueBody = `
<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="rev-from" value="${revFrom}"
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="rev-to" value="${revTo}"
class="px-3 py-1.5 border border-gray-300 rounded-md text-sm">
</div>
<button onclick="window.reportsView.loadRevenue()"
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">
Group by month
</label>
<label class="flex items-center gap-1 pt-5 text-xs text-gray-600 cursor-pointer">
<input type="checkbox" id="rev-anonymize" ${revAnonymize ? 'checked' : ''}
class="h-4 w-4 text-blue-600 border-gray-300 rounded">
Anonymize customers
</label>
</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">
<div><label class="block text-xs font-medium text-gray-700 mb-1">Start</label>
<input type="date" id="pl-start" value="${plStartDate}" 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">End</label>
<input type="date" id="pl-end" value="${plEndDate}" 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">Method</label>
<select id="pl-method" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm">
<option value="Accrual" ${plAccountingMethod === 'Accrual' ? 'selected' : ''}>Accrual</option>
<option value="Cash" ${plAccountingMethod === 'Cash' ? 'selected' : ''}>Cash</option>
</select></div>
<button onclick="window.reportsView.loadProfitLoss()" 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.exportProfitLossPdf()" 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">📄 PDF</button>
</div>
<div id="pl-result"></div>`;
const balanceSheetBody = `
<div class="flex flex-wrap items-end gap-3 mb-3">
<div><label class="block text-xs font-medium text-gray-700 mb-1">As of</label>
<input type="date" id="bs-asof" value="${bsAsOfDate}" 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">Method</label>
<select id="bs-method" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm">
<option value="Accrual" ${bsAccountingMethod === 'Accrual' ? 'selected' : ''}>Accrual</option>
<option value="Cash" ${bsAccountingMethod === 'Cash' ? 'selected' : ''}>Cash</option>
</select></div>
<button onclick="window.reportsView.loadBalanceSheet()" 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.exportBalanceSheetPdf()" 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">📄 PDF</button>
</div>
<div id="bs-result"></div>`;
const taxSummaryBody = `
<div class="flex flex-wrap items-end gap-3 mb-3">
<div><label class="block text-xs font-medium text-gray-700 mb-1">Month</label>
<input type="month" id="ts-month" value="${tsMonth}" 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">Method</label>
<select id="ts-method" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm">
<option value="Accrual" ${tsAccountingMethod === 'Accrual' ? 'selected' : ''}>Accrual</option>
<option value="Cash" ${tsAccountingMethod === 'Cash' ? 'selected' : ''}>Cash</option>
</select></div>
<button onclick="window.reportsView.loadTaxSummary()" 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.exportTaxSummaryPdf()" 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">📄 PDF</button>
</div>
<div id="ts-result"></div>`;
const customerRevenueBody = `
<div class="flex flex-wrap items-end gap-3 mb-3">
<div><label class="block text-xs font-medium text-gray-700 mb-1">Start</label>
<input type="date" id="cr-start" value="${crStartDate}" 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">End</label>
<input type="date" id="cr-end" value="${crEndDate}" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm"></div>
<button onclick="window.reportsView.loadCustomerRevenue()" 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.exportCustomerRevenuePdf()" 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="cr-anonymize" class="h-4 w-4 text-blue-600 border-gray-300 rounded">
Anonymize
</label>
</div>
<div id="cr-result"></div>`;
el.innerHTML = `
<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)}
${reportCard('Sales Tax (QBO)', taxSummaryBody)}
</div>`;
// Alpine scannt das DOM nur beim Start — nachgereichtes Markup selbst anmelden.
initAlpineTree(el);
}
// ── Report 1: AR Aging ──────────────────────────────────────────────
export async function loadArAging() {
arAsOf = document.getElementById('ar-asof').value || today;
arAnonymize = document.getElementById('ar-anonymize')?.checked || false;
showLoading('ar-result', 'Loading receivables…');
try {
const data = await window.API.reports.getArAging(arAsOf, arAnonymize);
if (data.error) return reportError('ar-result', data.error);
arData = data;
renderArAging(data);
} catch (err) {
reportError('ar-result', err.message || 'Failed to load AR aging');
}
}
function renderArAging(data) {
if (!data.rows.length) {
document.getElementById('ar-result').innerHTML =
methodologyBox(data.methodology, data.anonymized) +
'<p class="text-sm text-gray-500">No open receivables as of this date.</p>';
return;
}
let rowsHtml = '';
for (const r of data.rows) {
const overdue = r.bucket !== 'current';
rowsHtml += `<tr class="border-t hover:bg-gray-50">
<td class="px-3 py-2 text-sm font-medium">${escapeHtml(r.customer_name || '—')}</td>
<td class="px-3 py-2 text-sm">${escapeHtml(r.invoice_number || '—')}</td>
<td class="px-3 py-2 text-sm text-gray-600">${formatDate(r.invoice_date)}</td>
<td class="px-3 py-2 text-sm text-gray-600">${formatDate(r.due_date)}</td>
<td class="px-3 py-2 text-sm text-right">${fmtMoney(r.original_amount)}</td>
<td class="px-3 py-2 text-sm text-right font-medium">${fmtMoney(r.open_amount)}</td>
<td class="px-3 py-2 text-sm text-center ${overdue ? 'text-red-700 font-medium' : 'text-gray-500'}">
${BUCKET_LABEL[r.bucket] || r.bucket}
</td>
</tr>`;
}
const t = data.totals;
rowsHtml += `<tr class="border-t-2 border-gray-300 bg-gray-50 font-semibold">
<td class="px-3 py-2 text-sm" colspan="4">TOTAL (${t.invoice_count} invoices)</td>
<td class="px-3 py-2 text-sm text-right">${fmtMoney(t.original_amount)}</td>
<td class="px-3 py-2 text-sm text-right">${fmtMoney(t.open_amount)}</td>
<td></td>
</tr>`;
let bucketCells = '';
for (const b of AR_BUCKETS) {
const entry = data.buckets[b.key];
bucketCells += `<tr class="border-t">
<td class="px-3 py-2 text-sm">${b.label}</td>
<td class="px-3 py-2 text-sm text-center text-gray-600">${entry.invoice_count}</td>
<td class="px-3 py-2 text-sm text-right">${fmtMoney(entry.open_amount)}</td>
</tr>`;
}
document.getElementById('ar-result').innerHTML =
methodologyBox(data.methodology, data.anonymized) + `
<div class="mb-4 max-w-md">
<div class="overflow-x-auto border border-gray-200 rounded">
<table class="min-w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left font-medium text-gray-700">Bucket</th>
<th class="px-3 py-2 text-center font-medium text-gray-700">Invoices</th>
<th class="px-3 py-2 text-right font-medium text-gray-700">Open</th>
</tr>
</thead>
<tbody>
${bucketCells}
<tr class="border-t-2 border-gray-300 bg-gray-50 font-semibold">
<td class="px-3 py-2 text-sm">Total</td>
<td class="px-3 py-2 text-sm text-center">${t.invoice_count}</td>
<td class="px-3 py-2 text-sm text-right">${fmtMoney(t.open_amount)}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="overflow-x-auto border border-gray-200 rounded">
<table class="min-w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left font-medium text-gray-700">Customer</th>
<th class="px-3 py-2 text-left font-medium text-gray-700">Invoice #</th>
<th class="px-3 py-2 text-left font-medium text-gray-700">Date</th>
<th class="px-3 py-2 text-left font-medium text-gray-700">Due</th>
<th class="px-3 py-2 text-right font-medium text-gray-700">Original</th>
<th class="px-3 py-2 text-right font-medium text-gray-700">Open</th>
<th class="px-3 py-2 text-center font-medium text-gray-700">Bucket</th>
</tr>
</thead>
<tbody>${rowsHtml}</tbody>
</table>
</div>`;
}
export function exportArAgingCsv() {
if (!arData || !arData.rows.length) {
return reportError('ar-result', 'Run the report first — there is nothing to export.');
}
const lines = [];
lines.push(csvRow([`Accounts Receivable Aging as of ${arData.asOf}`]));
lines.push(csvRow([`Methodology: ${arData.methodology}`]));
lines.push('');
lines.push(csvRow(['Customer', 'Invoice #', 'Invoice Date', 'Due Date', 'Original Amount', 'Open Amount', 'Bucket']));
for (const r of arData.rows) {
lines.push(csvRow([
r.customer_name || '',
r.invoice_number || '',
String(r.invoice_date).split('T')[0],
String(r.due_date).split('T')[0],
r.original_amount,
r.open_amount,
BUCKET_LABEL[r.bucket] || r.bucket
]));
}
const t = arData.totals;
lines.push(csvRow(['TOTAL', '', '', '', t.original_amount, t.open_amount, `${t.invoice_count} invoices`]));
lines.push('');
lines.push(csvRow(['Bucket Summary', 'Invoices', 'Open Amount']));
for (const b of AR_BUCKETS) {
const entry = arData.buckets[b.key];
lines.push(csvRow([b.label, entry.invoice_count, entry.open_amount]));
}
lines.push(csvRow(['Total', t.invoice_count, t.open_amount]));
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() {
revFrom = document.getElementById('rev-from').value;
revTo = document.getElementById('rev-to').value;
revAnonymize = document.getElementById('rev-anonymize')?.checked || false;
revGroupByMonth = document.getElementById('rev-group-month')?.checked || false;
if (!revFrom || !revTo) return reportError('rev-result', 'Please select both a from and a to date.');
if (revFrom > revTo) return reportError('rev-result', 'The from date must not be after the to date.');
showLoading('rev-result', 'Loading revenue…');
try {
const data = await window.API.reports.getRevenue(revFrom, revTo, revAnonymize, revGroupByMonth);
if (data.error) return reportError('rev-result', data.error);
revData = data;
renderRevenue(data);
} catch (err) {
reportError('rev-result', err.message || 'Failed to load revenue');
}
}
function revenueRowHtml(r) {
return `<tr class="border-t hover:bg-gray-50">
<td class="px-3 py-2 text-sm font-medium">${escapeHtml(r.customer_name || '—')}</td>
<td class="px-3 py-2 text-sm">${escapeHtml(r.invoice_number || '—')}</td>
<td class="px-3 py-2 text-sm text-gray-600">${formatDate(r.invoice_date)}</td>
<td class="px-3 py-2 text-sm text-right">${fmtMoney(r.subtotal)}</td>
<td class="px-3 py-2 text-sm text-right">${fmtMoney(r.tax_amount)}</td>
<td class="px-3 py-2 text-sm text-right font-medium">${fmtMoney(r.total)}</td>
<td class="px-3 py-2 text-sm text-right">${fmtMoney(r.paid_amount)}</td>
<td class="px-3 py-2 text-sm text-right">${fmtMoney(r.open_amount)}</td>
<td class="px-3 py-2 text-sm text-center text-gray-500">${escapeHtml(r.payment_status || '—')}</td>
</tr>`;
}
function revenueSummaryRow(label, s, extraClass) {
return `<tr class="${extraClass}">
<td class="px-3 py-2 text-sm" colspan="3">${escapeHtml(label)} (${s.invoice_count} invoices)</td>
<td class="px-3 py-2 text-sm text-right">${fmtMoney(s.subtotal)}</td>
<td class="px-3 py-2 text-sm text-right">${fmtMoney(s.tax_amount)}</td>
<td class="px-3 py-2 text-sm text-right">${fmtMoney(s.total)}</td>
<td class="px-3 py-2 text-sm text-right">${fmtMoney(s.paid_amount)}</td>
<td class="px-3 py-2 text-sm text-right">${fmtMoney(s.open_amount)}</td>
<td></td>
</tr>`;
}
function renderRevenue(data) {
if (!data.rows.length) {
document.getElementById('rev-result').innerHTML =
methodologyBox(data.methodology, data.anonymized) +
'<p class="text-sm text-gray-500">No invoices found in this period.</p>';
return;
}
let rowsHtml = '';
if (data.groupByMonth) {
// Monats-Zwischensummen kommen aus SQL, nicht aus JS-Addition
const sumsByMonth = new Map(data.months.map(m => [m.month_key, m]));
const groupClass = 'bg-gray-50 border-t-2 border-gray-300 font-semibold text-gray-700';
let currentMonth = null;
for (const r of data.rows) {
if (r.month_key !== currentMonth) {
if (currentMonth !== null) {
rowsHtml += revenueSummaryRow('Group Total', sumsByMonth.get(currentMonth), groupClass);
}
currentMonth = r.month_key;
rowsHtml += `<tr class="bg-blue-50"><td colspan="9" class="px-3 py-2 text-sm font-bold text-blue-800">📅 ${escapeHtml(monthLabel(r.month_key))}</td></tr>`;
}
rowsHtml += revenueRowHtml(r);
}
if (currentMonth !== null) {
rowsHtml += revenueSummaryRow('Group Total', sumsByMonth.get(currentMonth), groupClass);
}
} else {
for (const r of data.rows) rowsHtml += revenueRowHtml(r);
}
rowsHtml += revenueSummaryRow('TOTAL', data.totals, 'border-t-2 border-gray-400 bg-gray-100 font-bold');
document.getElementById('rev-result').innerHTML =
methodologyBox(data.methodology, data.anonymized) + `
<div class="overflow-x-auto border border-gray-200 rounded">
<table class="min-w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left font-medium text-gray-700">Customer</th>
<th class="px-3 py-2 text-left font-medium text-gray-700">Invoice #</th>
<th class="px-3 py-2 text-left font-medium text-gray-700">Date</th>
<th class="px-3 py-2 text-right font-medium text-gray-700">Subtotal</th>
<th class="px-3 py-2 text-right font-medium text-gray-700">Tax</th>
<th class="px-3 py-2 text-right font-medium text-gray-700">Total</th>
<th class="px-3 py-2 text-right font-medium text-gray-700">Paid</th>
<th class="px-3 py-2 text-right font-medium text-gray-700">Open</th>
<th class="px-3 py-2 text-center font-medium text-gray-700">Status</th>
</tr>
</thead>
<tbody>${rowsHtml}</tbody>
</table>
</div>`;
}
export function exportRevenueCsv() {
if (!revData || !revData.rows.length) {
return reportError('rev-result', 'Run the report first — there is nothing to export.');
}
const lines = [];
lines.push(csvRow([`Invoice-Level Revenue ${revData.from} to ${revData.to}`]));
lines.push(csvRow([`Methodology: ${revData.methodology}`]));
lines.push('');
const header = ['Customer', 'Invoice #', 'Invoice Date', 'Subtotal', 'Tax', 'Total', 'Paid', 'Open', 'Payment Status'];
if (revData.groupByMonth) header.unshift('Month');
lines.push(csvRow(header));
for (const r of revData.rows) {
const cells = [
r.customer_name || '',
r.invoice_number || '',
String(r.invoice_date).split('T')[0],
r.subtotal, r.tax_amount, r.total, r.paid_amount, r.open_amount,
r.payment_status || ''
];
if (revData.groupByMonth) cells.unshift(r.month_key);
lines.push(csvRow(cells));
}
const t = revData.totals;
const totalCells = [`TOTAL (${t.invoice_count} invoices)`, '', '',
t.subtotal, t.tax_amount, t.total, t.paid_amount, t.open_amount, ''];
if (revData.groupByMonth) totalCells.unshift('');
lines.push(csvRow(totalCells));
if (revData.groupByMonth && revData.months.length) {
lines.push('');
lines.push(csvRow(['Month', 'Invoices', 'Subtotal', 'Tax', 'Total', 'Paid', 'Open']));
for (const m of revData.months) {
lines.push(csvRow([m.month_key, m.invoice_count, m.subtotal, m.tax_amount, m.total, m.paid_amount, m.open_amount]));
}
}
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');
}
// ── 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
// zugehörigen Markup und der Titel-Parameter von showError.
// ────────────────────────────────────────────────────────────────────
export async function loadProfitLoss() {
plStartDate = document.getElementById('pl-start').value;
plEndDate = document.getElementById('pl-end').value;
plAccountingMethod = document.getElementById('pl-method').value;
showLoading('pl-result', 'Loading P&L from QBO…');
try {
const data = await window.API.accounting.getProfitAndLoss(plStartDate, plEndDate, plAccountingMethod);
if (data.error) return showError('pl-result', data.error);
document.getElementById('pl-result').innerHTML = renderQboReport(data);
} catch (err) { showError('pl-result', err.message || 'Failed to load P&L'); }
}
export async function loadBalanceSheet() {
bsAsOfDate = document.getElementById('bs-asof').value;
bsAccountingMethod = document.getElementById('bs-method').value;
showLoading('bs-result', 'Loading Balance Sheet from QBO…');
try {
const data = await window.API.accounting.getBalanceSheet(bsAsOfDate, bsAccountingMethod);
if (data.error) return showError('bs-result', data.error);
document.getElementById('bs-result').innerHTML = renderQboReport(data);
} catch (err) { showError('bs-result', err.message || 'Failed to load Balance Sheet'); }
}
export async function loadTaxSummary() {
tsMonth = document.getElementById('ts-month').value;
tsAccountingMethod = document.getElementById('ts-method').value;
if (!tsMonth) return showError('ts-result', 'Please select a month.');
const [y, m] = tsMonth.split('-').map(Number);
const startDate = firstOfMonthISO(y, m - 1);
const endDate = lastOfMonthISO(y, m - 1);
showLoading('ts-result', 'Loading Sales Tax Liability from QBO…');
try {
const data = await window.API.accounting.getTaxSummary(startDate, endDate, tsAccountingMethod);
if (data.error) return showError('ts-result', data.error);
document.getElementById('ts-result').innerHTML = renderQboReport(data);
} catch (err) { showError('ts-result', err.message || 'Failed to load Tax Summary'); }
}
export async function loadCustomerRevenue() {
crStartDate = document.getElementById('cr-start').value;
crEndDate = document.getElementById('cr-end').value;
const anonymize = document.getElementById('cr-anonymize')?.checked || false;
if (!crStartDate || !crEndDate) return showError('cr-result', 'Please select both start and end dates.');
showLoading('cr-result', 'Loading customer revenue...');
const maskName = (name) => anonymize ? name.charAt(0) : name;
try {
const data = await window.API.accounting.getCustomerRevenue(crStartDate, crEndDate);
if (data.error) return showError('cr-result', data.error);
if (!data.length) {
document.getElementById('cr-result').innerHTML = '<p class="text-sm text-gray-500">No invoices found in this period.</p>';
return;
}
const grandTotal = parseFloat(data[0].grand_total) || 0;
const totalInvoices = data.reduce((s, r) => s + parseInt(r.invoice_count), 0);
let rowsHtml = '';
let rank = 0;
for (const r of data) {
rank++;
const rev = parseFloat(r.total_revenue) || 0;
const pct = grandTotal > 0 ? ((rev / grandTotal) * 100).toFixed(1) : '0.0';
rowsHtml += `<tr class="border-t hover:bg-gray-50">
<td class="px-3 py-2 text-sm font-medium">${rank}. ${escapeHtml(maskName(r.customer_name))}</td>
<td class="px-3 py-2 text-sm text-center">${r.invoice_count}</td>
<td class="px-3 py-2 text-sm text-right">${fmtMoney(rev)}</td>
<td class="px-3 py-2 text-sm text-right text-gray-500">${pct}%</td>
</tr>`;
}
rowsHtml += `<tr class="border-t bg-gray-50 font-semibold">
<td class="px-3 py-2 text-sm">TOTAL (${data.length} customers)</td>
<td class="px-3 py-2 text-sm text-center">${totalInvoices}</td>
<td class="px-3 py-2 text-sm text-right">${fmtMoney(grandTotal)}</td>
<td class="px-3 py-2 text-sm text-right">100.0%</td>
</tr>`;
document.getElementById('cr-result').innerHTML = `
<div class="overflow-x-auto border border-gray-200 rounded">
<table class="min-w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left font-medium text-gray-700">Customer</th>
<th class="px-3 py-2 text-center font-medium text-gray-700">Invoices</th>
<th class="px-3 py-2 text-right font-medium text-gray-700">Revenue (net)</th>
<th class="px-3 py-2 text-right font-medium text-gray-700">% of Total</th>
</tr>
</thead>
<tbody>${rowsHtml}</tbody>
</table>
</div>`;
} catch (err) { showError('cr-result', err.message || 'Failed to load revenue report'); }
}
export function exportCustomerRevenuePdf() {
const startEl = document.getElementById('cr-start');
const endEl = document.getElementById('cr-end');
const anonymize = document.getElementById('cr-anonymize')?.checked || false;
if (!startEl?.value || !endEl?.value) return alert('Please select start and end dates first.');
let url = `/api/accounting/reports/customer-revenue/pdf?startDate=${startEl.value}&endDate=${endEl.value}`;
if (anonymize) url += '&anonymize=true';
window.open(url, '_blank');
}
export function exportProfitLossPdf() {
const startEl = document.getElementById('pl-start');
const endEl = document.getElementById('pl-end');
const method = document.getElementById('pl-method')?.value || 'Accrual';
if (!startEl?.value || !endEl?.value) return alert('Please select start and end dates first.');
window.open(`/api/accounting/reports/profit-loss/pdf?startDate=${startEl.value}&endDate=${endEl.value}&accountingMethod=${method}`, '_blank');
}
export function exportBalanceSheetPdf() {
const asOf = document.getElementById('bs-asof');
const method = document.getElementById('bs-method')?.value || 'Accrual';
if (!asOf?.value) return alert('Please select an as-of date first.');
window.open(`/api/accounting/reports/balance-sheet/pdf?asOfDate=${asOf.value}&accountingMethod=${method}`, '_blank');
}
export function exportTaxSummaryPdf() {
const monthEl = document.getElementById('ts-month');
const method = document.getElementById('ts-method')?.value || 'Accrual';
if (!monthEl?.value) return alert('Please select a month first.');
const [y, m] = monthEl.value.split('-').map(Number);
const startDate = firstOfMonthISO(y, m - 1);
const endDate = lastOfMonthISO(y, m - 1);
window.open(`/api/accounting/reports/tax-summary/pdf?startDate=${startDate}&endDate=${endDate}&accountingMethod=${method}`, '_blank');
}
// ── Expose for onclick handlers ─────────────────────────────────────
window.reportsView = {
renderReportsView,
loadArAging,
exportArAgingCsv,
exportArAgingPdf,
loadRevenue,
exportRevenueCsv,
exportRevenuePdf,
loadCategoryRevenue,
exportCategoryRevenueCsv,
exportCategoryRevenuePdf,
loadProfitLoss,
loadBalanceSheet,
loadTaxSummary,
loadCustomerRevenue,
exportProfitLossPdf,
exportBalanceSheetPdf,
exportTaxSummaryPdf,
exportCustomerRevenuePdf
};