/** * 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 { formatDate } from '../utils/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; 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 (Stil wie accounting-view.js) ───────────────────── /** numeric-String → "$1,234.56". Kein Float-Zwischenschritt außer zur Anzeige. */ function fmtMoney(v) { if (v == null || v === '') return '—'; const n = parseFloat(v); if (isNaN(n)) return '—'; const fixed = n.toFixed(2); const [intPart, decPart] = fixed.replace('-', '').split('.'); const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ','); return `${n < 0 ? '-' : ''}$${grouped}.${decPart}`; } function escapeHtml(s) { if (s == null) return ''; return String(s) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } function showError(slotId, message) { const el = document.getElementById(slotId); if (!el) return; el.innerHTML = `

Report Error

${escapeHtml(message)}

`; } function showLoading(slotId, message = 'Loading…') { const el = document.getElementById(slotId); if (!el) return; el.innerHTML = `
${escapeHtml(message)}
`; } 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 ─────────────────────────────────────────────────────────── export function renderReportsView() { const el = document.getElementById('reports-content'); if (!el) return; el.innerHTML = `

Accounts Receivable Aging

Invoice-Level Revenue

`; } // ── 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 showError('ar-result', data.error); arData = data; renderArAging(data); } catch (err) { showError('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 showError('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 showError('rev-result', 'Please select both a from and a to date.'); if (revFrom > revTo) return showError('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 showError('rev-result', data.error); revData = data; renderRevenue(data); } catch (err) { showError('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 showError('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); } // ── Expose for onclick handlers ───────────────────────────────────── window.reportsView = { loadArAging, exportArAgingCsv, loadRevenue, exportRevenueCsv };