customer report
This commit is contained in:
@@ -156,6 +156,12 @@ const API = {
|
|||||||
method: 'POST'
|
method: 'POST'
|
||||||
}).then(r => r.json()),
|
}).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
|
// Phase 2 Lieferung 1 — Sync + Cache-Reads
|
||||||
syncAccounts: () => fetch('/api/accounting/sync-accounts', { method: 'POST' }).then(r => r.json()),
|
syncAccounts: () => fetch('/api/accounting/sync-accounts', { method: 'POST' }).then(r => r.json()),
|
||||||
syncVendors: () => fetch('/api/accounting/sync-vendors', { method: 'POST' }).then(r => r.json()),
|
syncVendors: () => fetch('/api/accounting/sync-vendors', { method: 'POST' }).then(r => r.json()),
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ let bsAccountingMethod = 'Accrual';
|
|||||||
let tsMonth = null; // 'YYYY-MM' — selected month for tax summary
|
let tsMonth = null; // 'YYYY-MM' — selected month for tax summary
|
||||||
let tsAccountingMethod = 'Accrual';
|
let tsAccountingMethod = 'Accrual';
|
||||||
|
|
||||||
|
let crStartDate = null;
|
||||||
|
let crEndDate = null;
|
||||||
|
|
||||||
let stPeriods = [];
|
let stPeriods = [];
|
||||||
let stEditingPeriodId = null; // current period in detail dialog, null = new
|
let stEditingPeriodId = null; // current period in detail dialog, null = new
|
||||||
let stAccounts = []; // cached account list for pickers
|
let stAccounts = []; // cached account list for pickers
|
||||||
@@ -434,6 +437,8 @@ export function injectReportsControls() {
|
|||||||
if (!plEndDate) plEndDate = todayISO();
|
if (!plEndDate) plEndDate = todayISO();
|
||||||
if (!bsAsOfDate) bsAsOfDate = todayISO();
|
if (!bsAsOfDate) bsAsOfDate = todayISO();
|
||||||
if (!tsMonth) tsMonth = prevMonthISO();
|
if (!tsMonth) tsMonth = prevMonthISO();
|
||||||
|
if (!crStartDate) crStartDate = firstOfYearISO();
|
||||||
|
if (!crEndDate) crEndDate = todayISO();
|
||||||
|
|
||||||
c.innerHTML = `
|
c.innerHTML = `
|
||||||
${makeCollapsible('Reports', 'reports-section-body')}
|
${makeCollapsible('Reports', 'reports-section-body')}
|
||||||
@@ -489,6 +494,20 @@ export function injectReportsControls() {
|
|||||||
<div id="ts-result"></div>
|
<div id="ts-result"></div>
|
||||||
</div>
|
</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>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -531,6 +550,65 @@ export async function loadTaxSummary() {
|
|||||||
} catch (err) { showError('ts-result', err.message || 'Failed to load Tax Summary'); }
|
} 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) {
|
function renderQboReport(report) {
|
||||||
if (!report || !report.Header) return `<p class="text-sm text-gray-500">No report data.</p>`;
|
if (!report || !report.Header) return `<p class="text-sm text-gray-500">No report data.</p>`;
|
||||||
const cols = (report.Columns && report.Columns.Column) || [];
|
const cols = (report.Columns && report.Columns.Column) || [];
|
||||||
@@ -1300,5 +1378,7 @@ window.accountingView = {
|
|||||||
updateTaxPreview,
|
updateTaxPreview,
|
||||||
saveTaxPeriodDraft,
|
saveTaxPeriodDraft,
|
||||||
recordTaxPayment,
|
recordTaxPayment,
|
||||||
markTaxPaidExternal
|
markTaxPaidExternal,
|
||||||
|
loadCustomerRevenue,
|
||||||
|
exportCustomerRevenuePdf
|
||||||
};
|
};
|
||||||
@@ -5,7 +5,12 @@
|
|||||||
*/
|
*/
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs').promises;
|
||||||
const accountingService = require('../services/accounting-service');
|
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');
|
const multer = require('multer');
|
||||||
|
|
||||||
// Limit aus ENV, default 5 MB. Akzeptierte Werte z.B. "5", "10", "20"
|
// 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'); }
|
} 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
|
// Phase 2 Lieferung 1 — Sync + Cache-Reads
|
||||||
// ════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
65
templates/customer-revenue-template.html
Normal file
65
templates/customer-revenue-template.html
Normal 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 & Consulting</div>
|
||||||
|
|
||||||
|
<div class="report-info">
|
||||||
|
<h2>Customer Revenue Report</h2>
|
||||||
|
<p>Period: {{DATE_RANGE}} · 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>
|
||||||
Reference in New Issue
Block a user