/** * 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) * * 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. * * 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; // 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 `
Methodology: ${escapeHtml(text)} ${anonymized ? 'anonymized' : ''}
`; } 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 `

${title}

`; } 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 = `
`; const revenueBody = `
`; // ── Ab hier 1:1 aus accounting-view.js injectReportsControls() ── const profitLossBody = `
`; const balanceSheetBody = `
`; const taxSummaryBody = `
`; const customerRevenueBody = `
`; el.innerHTML = `
${reportCard('Accounts Receivable Aging', arAgingBody)} ${reportCard('Invoice-Level Revenue', revenueBody)} ${reportCard('Customer Revenue (Summary)', customerRevenueBody)} ${reportCard('Profit & Loss', profitLossBody)} ${reportCard('Balance Sheet', balanceSheetBody)} ${reportCard('Sales Tax (QBO)', taxSummaryBody)}
`; // 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) + '

No open receivables as of this date.

'; return; } let rowsHtml = ''; for (const r of data.rows) { const overdue = r.bucket !== 'current'; rowsHtml += ` ${escapeHtml(r.customer_name || '—')} ${escapeHtml(r.invoice_number || '—')} ${formatDate(r.invoice_date)} ${formatDate(r.due_date)} ${fmtMoney(r.original_amount)} ${fmtMoney(r.open_amount)} ${BUCKET_LABEL[r.bucket] || r.bucket} `; } const t = data.totals; rowsHtml += ` TOTAL (${t.invoice_count} invoices) ${fmtMoney(t.original_amount)} ${fmtMoney(t.open_amount)} `; let bucketCells = ''; for (const b of AR_BUCKETS) { const entry = data.buckets[b.key]; bucketCells += ` ${b.label} ${entry.invoice_count} ${fmtMoney(entry.open_amount)} `; } document.getElementById('ar-result').innerHTML = methodologyBox(data.methodology, data.anonymized) + `
${bucketCells}
Bucket Invoices Open
Total ${t.invoice_count} ${fmtMoney(t.open_amount)}
${rowsHtml}
Customer Invoice # Date Due Original Open Bucket
`; } 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); } // ── 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 ` ${escapeHtml(r.customer_name || '—')} ${escapeHtml(r.invoice_number || '—')} ${formatDate(r.invoice_date)} ${fmtMoney(r.subtotal)} ${fmtMoney(r.tax_amount)} ${fmtMoney(r.total)} ${fmtMoney(r.paid_amount)} ${fmtMoney(r.open_amount)} ${escapeHtml(r.payment_status || '—')} `; } function revenueSummaryRow(label, s, extraClass) { return ` ${escapeHtml(label)} (${s.invoice_count} invoices) ${fmtMoney(s.subtotal)} ${fmtMoney(s.tax_amount)} ${fmtMoney(s.total)} ${fmtMoney(s.paid_amount)} ${fmtMoney(s.open_amount)} `; } function renderRevenue(data) { if (!data.rows.length) { document.getElementById('rev-result').innerHTML = methodologyBox(data.methodology, data.anonymized) + '

No invoices found in this period.

'; 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 += `📅 ${escapeHtml(monthLabel(r.month_key))}`; } 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) + `
${rowsHtml}
Customer Invoice # Date Subtotal Tax Total Paid Open Status
`; } 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); } // ──────────────────────────────────────────────────────────────────── // 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 = '

No invoices found in this period.

'; 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 += ` ${rank}. ${escapeHtml(maskName(r.customer_name))} ${r.invoice_count} ${fmtMoney(rev)} ${pct}% `; } rowsHtml += ` TOTAL (${data.length} customers) ${totalInvoices} ${fmtMoney(grandTotal)} 100.0% `; document.getElementById('cr-result').innerHTML = `
${rowsHtml}
Customer Invoices Revenue (net) % of Total
`; } 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, loadRevenue, exportRevenueCsv, loadProfitLoss, loadBalanceSheet, loadTaxSummary, loadCustomerRevenue, exportProfitLossPdf, exportBalanceSheetPdf, exportTaxSummaryPdf, exportCustomerRevenuePdf };