diff --git a/public/js/utils/api.js b/public/js/utils/api.js index 7447ebb..0f4ee07 100644 --- a/public/js/utils/api.js +++ b/public/js/utils/api.js @@ -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()), diff --git a/public/js/views/accounting-view.js b/public/js/views/accounting-view.js index 5f43fbe..8e3a7fe 100644 --- a/public/js/views/accounting-view.js +++ b/public/js/views/accounting-view.js @@ -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() {
+
+

Customer Revenue

+
+
+
+
+
+
+ + +
+
+
+
`; } @@ -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 = '

No invoices found in this period.

'; + 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 += ` + ${rank}. ${escapeHtml(r.customer_name)} + ${r.invoice_count} + ${fmtMoney(rev)} + ${pct}% + `; + } + rowsHtml += ` + TOTAL (${data.length} customers) + ${totalInvoices} + ${fmtMoney(grandTotal)} + 100.0% + `; + + document.getElementById('cr-result').innerHTML = ` +
+ + + + + + + + + + ${rowsHtml} +
CustomerInvoicesRevenue (net)% of Total
+
`; + } 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 `

No report data.

`; const cols = (report.Columns && report.Columns.Column) || []; @@ -1300,5 +1378,7 @@ window.accountingView = { updateTaxPreview, saveTaxPeriodDraft, recordTaxPayment, - markTaxPaidExternal + markTaxPaidExternal, + loadCustomerRevenue, + exportCustomerRevenuePdf }; \ No newline at end of file diff --git a/src/routes/accounting.js b/src/routes/accounting.js index 0623f1b..dda7274 100644 --- a/src/routes/accounting.js +++ b/src/routes/accounting.js @@ -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 += ` + ${rank}. ${r.customer_name} + ${r.invoice_count} + $${formatMoney(rev)} + ${pct}% + `; + } + rowsHtml += ` + TOTAL (${rows.length} customers) + ${totalInvoices} + $${formatMoney(grandTotal)} + 100.0% + `; + + 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 // ════════════════════════════════════════════════════════════════════ diff --git a/templates/customer-revenue-template.html b/templates/customer-revenue-template.html new file mode 100644 index 0000000..ba97b1e --- /dev/null +++ b/templates/customer-revenue-template.html @@ -0,0 +1,65 @@ + + + + + + + +
+
+
+ {{LOGO_HTML}} +
+

Thumann IT LLC

+

4639 Corona Dr, Ste 39
Corpus Christi, TX 78411

+
+
+
+
IT Services & Consulting
+ +
+

Customer Revenue Report

+

Period: {{DATE_RANGE}} · Generated: {{GENERATED_DATE}}

+
+ + + + + + + + + + + + {{ROWS}} + +
CustomerInvoicesRevenue (net)% of Total
+ +

+ Revenue = sum of invoice subtotals (excludes sales tax). Voided invoices are excluded. +

+
+ +