new reports

This commit is contained in:
2026-07-25 17:54:31 -05:00
parent 248affc0f9
commit b0081080f6
7 changed files with 1100 additions and 23 deletions

View File

@@ -25,6 +25,7 @@ import './modals/payment-modal.js';
import './modals/email-modal.js';
import { setDefaultDate } from './utils/helpers.js';
import { renderAccountingView } from './views/accounting-view.js';
import { renderReportsView } from './views/reports-view.js';
// ============================================================
// Tab Management
@@ -51,7 +52,9 @@ function showTab(tabName) {
checkCurrentLogo();
} else if (tabName === 'accounting') {
renderAccountingView();
}
} else if (tabName === 'reports') {
renderReportsView();
}
}
// ============================================================
@@ -76,7 +79,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Hash-based navigation (e.g. after OAuth redirect /#settings)
if (window.location.hash) {
const hashTab = window.location.hash.replace('#', '');
if (['quotes', 'invoices', 'customers', 'accounting', 'settings'].includes(hashTab)) {
if (['quotes', 'invoices', 'customers', 'accounting', 'reports', 'settings'].includes(hashTab)) {
showTab(hashTab);
}
}

View File

@@ -223,6 +223,21 @@ const API = {
}
},
// Due-Diligence Reports API
reports: {
getArAging: (asOf, anonymize = false) => {
const params = new URLSearchParams({ asOf });
if (anonymize) params.set('anonymize', 'true');
return fetch('/api/reports/ar-aging?' + params.toString()).then(r => r.json());
},
getRevenue: (from, to, anonymize = false, groupByMonth = false) => {
const params = new URLSearchParams({ from, to });
if (anonymize) params.set('anonymize', 'true');
if (groupByMonth) params.set('groupByMonth', 'true');
return fetch('/api/reports/revenue?' + params.toString()).then(r => r.json());
}
},
// Settings API
settings: {
getLogo: () => fetch('/api/logo-info').then(r => r.json()),

View File

@@ -0,0 +1,482 @@
/**
* 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
};