Fix financial double-counting, rebuild cashup accountability, and redesign the Reports page
Financial correctness (donation-leg model):
- Donations are no longer mutated when assigned to a registration; assignment now
creates an immutable "leg" record referencing the original donation instead.
- Fixed several places where money was double-counted once a donation was partially
or fully assigned (Payments, Revenue summary, Cashup reconciliation, Finance
report, Profit report, Master Orders, Revenue Detailed).
- Payments now record who recorded them (recordedBy), separate from who they're for.
Cashup:
- Per-user cash denomination counting (optional, any time) replaces the single
event-wide manual entry; the event's cash actual is the live sum of these counts.
- New "Payment accountability by staff member" breakdown across all methods, and a
read-only "Report" tab that opens automatically once an event is closed.
Reports page redesign:
- New shell: sidebar of universal filters (events, date range, past/inactive/closed
toggles), searchable/categorized report grid, and a popup viewer with
Print/Email/Excel/WhatsApp actions plus an in-app Reporting Guide.
- Visual pass: colored stat tiles and bar charts on most reports, matching mockups.
- PDF exports (download/Print/Email/WhatsApp) now share a branded design mirroring
the web report — colored header, stat tiles, bar chart, highlighted totals.
- Excel export now produces a styled .xlsx (via exceljs) instead of a plain CSV.
- Master Orders' "Donations made" table is now included in every export channel.
Bug fixes discovered while testing exports:
- Report emails now go through the shared, DB-configurable mail utility instead of
a one-off transporter that ignored Site Settings SMTP config.
- WhatsApp report sends now surface the actual WAWP API error and auto-recover a
disconnected session, instead of a bare axios status-code message.
Also: Admin-editable notification preference, richer Admin Registrations dashboard,
{{payment.link}} placeholder for Email/WhatsApp Attendees, and background
email/WhatsApp attendee sending.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -31,13 +31,26 @@ function valueToString(v: any): string {
|
||||
return String(v);
|
||||
}
|
||||
|
||||
// New: server-side PDF generation and email helpers
|
||||
// New: server-side PDF/Excel generation and email helpers
|
||||
export type ReportStat = { label: string; value: string; tone?: 'green' | 'blue' | 'violet' | 'amber' | 'rose' | 'gray' };
|
||||
export type ReportChartDatum = { label: string; value: number; displayValue?: string };
|
||||
|
||||
export type ReportPdfPayload = {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
kind: 'table' | 'layered';
|
||||
orientation?: 'portrait' | 'landscape';
|
||||
// Optional visual sections rendered above the table/layered body — mirrors the on-screen
|
||||
// report's stat tiles / bar chart / explanatory note, so exported PDF/Excel/email/WhatsApp
|
||||
// all look like the same report the web UI shows, not a plain data dump.
|
||||
stats?: ReportStat[];
|
||||
chart?: { title?: string; data: ReportChartDatum[] };
|
||||
note?: string;
|
||||
table?: { columns: string[]; rows: (string | number)[][] };
|
||||
layered?: { header?: string; sections: { title: string; items: string[] }[] };
|
||||
// Additional titled tables rendered below the main table/layered body — e.g. Master Orders'
|
||||
// separate "Donations made" breakdown, which isn't part of the Orders table itself.
|
||||
extraTables?: { title: string; columns: string[]; rows: (string | number)[][] }[];
|
||||
};
|
||||
|
||||
export async function downloadReportPdf(apiBase: string, authToken: string, payload: ReportPdfPayload) {
|
||||
@@ -64,6 +77,32 @@ export async function downloadReportPdf(apiBase: string, authToken: string, payl
|
||||
setTimeout(() => URL.revokeObjectURL(objUrl), 2000);
|
||||
}
|
||||
|
||||
// Downloads a styled .xlsx mirroring the same branded look as the PDF (colored header,
|
||||
// stat rows, a data-bar chart, banded table with a highlighted total row).
|
||||
export async function downloadReportExcel(apiBase: string, authToken: string, payload: ReportPdfPayload) {
|
||||
const url = `${apiBase}/api/reports/excel`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${authToken}`
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(text || `Failed to generate Excel file (${res.status})`);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const dl = document.createElement('a');
|
||||
const objUrl = URL.createObjectURL(blob);
|
||||
dl.href = objUrl;
|
||||
const safe = (payload.title || 'report').replace(/[^a-z0-9]/gi, '_').toLowerCase();
|
||||
dl.download = `${safe}.xlsx`;
|
||||
dl.click();
|
||||
setTimeout(() => URL.revokeObjectURL(objUrl), 2000);
|
||||
}
|
||||
|
||||
export async function emailReportPdf(apiBase: string, authToken: string, payload: ReportPdfPayload & { subject?: string; body?: string }) {
|
||||
const url = `${apiBase}/api/reports/email`;
|
||||
const res = await fetch(url, {
|
||||
@@ -81,6 +120,47 @@ export async function emailReportPdf(apiBase: string, authToken: string, payload
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Sends the report PDF to the current user's own WhatsApp number (same self-service pattern as
|
||||
// emailReportPdf — no recipient picker needed).
|
||||
export async function whatsappReportPdf(apiBase: string, authToken: string, payload: ReportPdfPayload & { caption?: string }) {
|
||||
const url = `${apiBase}/api/reports/whatsapp`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${authToken}`
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(text || `Failed to send PDF via WhatsApp (${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// "Print": fetches the same PDF as downloadReportPdf but opens it in a new tab instead of
|
||||
// downloading, so the browser's built-in PDF viewer's print button handles printing.
|
||||
export async function viewReportPdf(apiBase: string, authToken: string, payload: ReportPdfPayload) {
|
||||
const url = `${apiBase}/api/reports/pdf`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${authToken}`
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(text || `Failed to generate PDF (${res.status})`);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const objUrl = URL.createObjectURL(blob);
|
||||
window.open(objUrl, '_blank');
|
||||
setTimeout(() => URL.revokeObjectURL(objUrl), 60000);
|
||||
}
|
||||
|
||||
// Legacy (used elsewhere). Kept in case other code paths still rely on print flow.
|
||||
export function openPrintWindow(title: string, htmlContent: string) {
|
||||
const w = window.open('', '_blank');
|
||||
|
||||
Reference in New Issue
Block a user