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() {
+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 += `| Customer | +Invoices | +Revenue (net) | +% of Total | +
|---|
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 += `4639 Corona Dr, Ste 39
Corpus Christi, TX 78411
Period: {{DATE_RANGE}} · Generated: {{GENERATED_DATE}}
+| Customer | +Invoices | +Revenue (net) | +% of Total | +
|---|
+ Revenue = sum of invoice subtotals (excludes sales tax). Voided invoices are excluded. +
+