Files
invoice-system/public/js/views/reports-view.js
2026-07-25 17:54:31 -05:00

483 lines
22 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)
*
* 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: '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 (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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function showError(slotId, message) {
const el = document.getElementById(slotId);
if (!el) return;
el.innerHTML = `
<div class="p-4 bg-red-50 border border-red-200 rounded-lg">
<p class="font-semibold text-red-800">Report Error</p>
<p class="text-sm text-red-600 mt-1">${escapeHtml(message)}</p>
</div>`;
}
function showLoading(slotId, message = 'Loading…') {
const el = document.getElementById(slotId);
if (!el) return;
el.innerHTML = `
<div class="flex items-center gap-3 p-4 text-gray-500">
<svg class="animate-spin h-5 w-5 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<span>${escapeHtml(message)}</span>
</div>`;
}
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 ───────────────────────────────────────────────────────────
export function renderReportsView() {
const el = document.getElementById('reports-content');
if (!el) return;
el.innerHTML = `
<div class="space-y-6">
<!-- Report 1: AR Aging -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
<div class="px-4 py-3 border-b bg-gray-50">
<h3 class="font-semibold text-gray-800">Accounts Receivable Aging</h3>
</div>
<div class="p-4">
<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>
<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>
</div>
</div>
<!-- Report 2: Invoice-Level Revenue -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
<div class="px-4 py-3 border-b bg-gray-50">
<h3 class="font-semibold text-gray-800">Invoice-Level Revenue</h3>
</div>
<div class="p-4">
<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>
<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>
</div>
</div>
</div>`;
}
// ── 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) +
'<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 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 `<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 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
};