918 lines
46 KiB
JavaScript
918 lines
46 KiB
JavaScript
/**
|
||
* 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: '1–30 days' },
|
||
{ key: 'd31_60', label: '31–60 days' },
|
||
{ key: 'd61_90', label: '61–90 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 & 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
|
||
};
|