customer report

This commit is contained in:
2026-06-10 15:51:43 -05:00
parent e4ffe085ff
commit fb12d66759
4 changed files with 264 additions and 1 deletions

View File

@@ -156,6 +156,12 @@ const API = {
method: 'POST'
}).then(r => r.json()),
// Customer Revenue Report
getCustomerRevenue: (startDate, endDate) => {
const params = new URLSearchParams({ startDate, endDate });
return fetch('/api/accounting/reports/customer-revenue?' + params.toString()).then(r => r.json());
},
// Phase 2 Lieferung 1 — Sync + Cache-Reads
syncAccounts: () => fetch('/api/accounting/sync-accounts', { method: 'POST' }).then(r => r.json()),
syncVendors: () => fetch('/api/accounting/sync-vendors', { method: 'POST' }).then(r => r.json()),

View File

@@ -30,6 +30,9 @@ let bsAccountingMethod = 'Accrual';
let tsMonth = null; // 'YYYY-MM' — selected month for tax summary
let tsAccountingMethod = 'Accrual';
let crStartDate = null;
let crEndDate = null;
let stPeriods = [];
let stEditingPeriodId = null; // current period in detail dialog, null = new
let stAccounts = []; // cached account list for pickers
@@ -434,6 +437,8 @@ export function injectReportsControls() {
if (!plEndDate) plEndDate = todayISO();
if (!bsAsOfDate) bsAsOfDate = todayISO();
if (!tsMonth) tsMonth = prevMonthISO();
if (!crStartDate) crStartDate = firstOfYearISO();
if (!crEndDate) crEndDate = todayISO();
c.innerHTML = `
${makeCollapsible('Reports', 'reports-section-body')}
@@ -489,6 +494,20 @@ export function injectReportsControls() {
<div id="ts-result"></div>
</div>
</div>
<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">Customer 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">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.accountingView.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.accountingView.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>
</div>
<div id="cr-result"></div>
</div>
</div>
</div>
</div>`;
}
@@ -531,6 +550,65 @@ export async function loadTaxSummary() {
} 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;
if (!crStartDate || !crEndDate) return showError('cr-result', 'Please select both start and end dates.');
showLoading('cr-result', 'Loading customer revenue...');
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(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');
if (!startEl?.value || !endEl?.value) return alert('Please select start and end dates first.');
const url = `/api/accounting/reports/customer-revenue/pdf?startDate=${startEl.value}&endDate=${endEl.value}`;
window.open(url, '_blank');
}
function renderQboReport(report) {
if (!report || !report.Header) return `<p class="text-sm text-gray-500">No report data.</p>`;
const cols = (report.Columns && report.Columns.Column) || [];
@@ -1300,5 +1378,7 @@ window.accountingView = {
updateTaxPreview,
saveTaxPeriodDraft,
recordTaxPayment,
markTaxPaidExternal
markTaxPaidExternal,
loadCustomerRevenue,
exportCustomerRevenuePdf
};

View File

@@ -5,7 +5,12 @@
*/
const express = require('express');
const router = express.Router();
const path = require('path');
const fs = require('fs').promises;
const accountingService = require('../services/accounting-service');
const { pool } = require('../config/database');
const { generatePdfFromHtml, getLogoHtml } = require('../services/pdf-service');
const { formatDate, formatMoney } = require('../utils/helpers');
const multer = require('multer');
// Limit aus ENV, default 5 MB. Akzeptierte Werte z.B. "5", "10", "20"
@@ -124,6 +129,113 @@ router.post('/sales-tax/periods/:id/mark-external', async (req, res) => {
} catch (err) { handleQboError(err, res, 'sales-tax-mark-external'); }
});
// ─── Customer Revenue Report ──────────────────────────────────────
router.get('/reports/customer-revenue', async (req, res) => {
try {
const { startDate, endDate } = req.query;
if (!startDate || !endDate) {
return res.status(400).json({ error: 'startDate and endDate are required' });
}
const result = await pool.query(
`SELECT
c.id,
c.name AS customer_name,
COUNT(i.id)::integer AS invoice_count,
COALESCE(SUM(i.subtotal), 0)::numeric(10,2) AS total_revenue,
(SELECT COALESCE(SUM(i2.subtotal), 0)::numeric(10,2)
FROM invoices i2
WHERE i2.invoice_date >= $1 AND i2.invoice_date <= $2
) AS grand_total
FROM customers c
JOIN invoices i ON i.customer_id = c.id
WHERE i.invoice_date >= $1 AND i.invoice_date <= $2
GROUP BY c.id, c.name
HAVING COUNT(i.id) > 0
ORDER BY total_revenue DESC`,
[startDate, endDate]
);
res.json(result.rows);
} catch (err) {
console.error('customer-revenue error:', err.message);
res.status(500).json({ error: err.message });
}
});
router.get('/reports/customer-revenue/pdf', async (req, res) => {
try {
const { startDate, endDate } = req.query;
if (!startDate || !endDate) {
return res.status(400).json({ error: 'startDate and endDate are required' });
}
const result = await pool.query(
`SELECT
c.name AS customer_name,
COUNT(i.id)::integer AS invoice_count,
COALESCE(SUM(i.subtotal), 0)::numeric(10,2) AS total_revenue,
(SELECT COALESCE(SUM(i2.subtotal), 0)::numeric(10,2)
FROM invoices i2
WHERE i2.invoice_date >= $1 AND i2.invoice_date <= $2
) AS grand_total
FROM customers c
JOIN invoices i ON i.customer_id = c.id
WHERE i.invoice_date >= $1 AND i.invoice_date <= $2
GROUP BY c.id, c.name
HAVING COUNT(i.id) > 0
ORDER BY total_revenue DESC`,
[startDate, endDate]
);
const rows = result.rows;
const grandTotal = rows.length > 0 ? parseFloat(rows[0].grand_total) || 0 : 0;
const totalInvoices = rows.reduce((s, r) => s + parseInt(r.invoice_count), 0);
let rowsHtml = '';
let rank = 0;
for (const r of rows) {
rank++;
const rev = parseFloat(r.total_revenue) || 0;
const pct = grandTotal > 0 ? ((rev / grandTotal) * 100).toFixed(1) : '0.0';
rowsHtml += `<tr>
<td class="name">${rank}. ${r.customer_name}</td>
<td class="number">${r.invoice_count}</td>
<td class="number">$${formatMoney(rev)}</td>
<td class="number">${pct}%</td>
</tr>`;
}
rowsHtml += `<tr class="footer-row">
<td class="total-label">TOTAL (${rows.length} customers)</td>
<td class="total-amount">${totalInvoices}</td>
<td class="total-amount">$${formatMoney(grandTotal)}</td>
<td class="total-amount">100.0%</td>
</tr>`;
const templatePath = path.join(__dirname, '..', '..', 'templates', 'customer-revenue-template.html');
let html = await fs.readFile(templatePath, 'utf-8');
const logoHTML = await getLogoHtml();
const dateRange = `${startDate} to ${endDate}`;
const generated = new Date().toISOString().split('T')[0];
html = html
.replace('{{LOGO_HTML}}', logoHTML)
.replace('{{DATE_RANGE}}', dateRange)
.replace('{{GENERATED_DATE}}', generated)
.replace('{{ROWS}}', rowsHtml);
const pdf = await generatePdfFromHtml(html);
res.set({
'Content-Type': 'application/pdf',
'Content-Length': pdf.length,
'Content-Disposition': `attachment; filename="Customer-Revenue-${startDate}-to-${endDate}.pdf"`
});
res.end(pdf, 'binary');
} catch (err) {
console.error('customer-revenue pdf error:', err.message);
res.status(500).json({ error: err.message });
}
});
// ════════════════════════════════════════════════════════════════════
// Phase 2 Lieferung 1 — Sync + Cache-Reads
// ════════════════════════════════════════════════════════════════════

View File

@@ -0,0 +1,65 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: Arial, sans-serif; font-size: 14px; line-height: 1.6; color: #333; }
.container { max-width: 8.5in; margin: 0 auto; padding: 20px; }
.header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 20px; padding-bottom: 20px; border-bottom: 2px solid #333; }
.company-info { display: flex; align-items: flex-start; gap: 15px; }
.logo { width: 50px; height: 50px; }
.company-details h1 { font-size: 16px; font-weight: normal; margin-bottom: 2px; }
.company-details p { font-size: 14px; line-height: 1.4; }
.tagline { text-align: right; font-style: italic; font-size: 14px; margin-bottom: 20px; }
.report-info { margin-bottom: 20px; }
.report-info h2 { font-size: 18px; margin-bottom: 4px; }
.report-info p { font-size: 13px; color: #555; }
.items-table { width: 100%; border-collapse: collapse; margin: 20px 0; font-size: 12px; }
.items-table th { background-color: #f5f5f5; border: 1px solid #000; padding: 8px; font-weight: bold; text-align: center; }
.items-table td { border: 1px solid #000; padding: 8px; }
.items-table td.name { text-align: left; }
.items-table td.number { text-align: right; }
.items-table tr.footer-row td { background-color: #f5f5f5; font-weight: bold; border-top: 2px solid #000; }
.items-table tr.footer-row td.total-label { text-align: right; }
.items-table tr.footer-row td.total-amount { text-align: right; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="company-info">
{{LOGO_HTML}}
<div class="company-details">
<h1>Thumann IT LLC</h1>
<p>4639 Corona Dr, Ste 39<br>Corpus Christi, TX 78411</p>
</div>
</div>
</div>
<div class="tagline">IT Services &amp; Consulting</div>
<div class="report-info">
<h2>Customer Revenue Report</h2>
<p>Period: {{DATE_RANGE}} &middot; Generated: {{GENERATED_DATE}}</p>
</div>
<table class="items-table">
<thead>
<tr>
<th>Customer</th>
<th>Invoices</th>
<th>Revenue (net)</th>
<th>% of Total</th>
</tr>
</thead>
<tbody>
{{ROWS}}
</tbody>
</table>
<p style="text-align:right; font-size:11px; color:#888; margin-top:10px;">
Revenue = sum of invoice subtotals (excludes sales tax). Voided invoices are excluded.
</p>
</div>
</body>
</html>