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:
2026-08-04 14:52:54 +02:00
co-authored by Claude Sonnet 5
parent 56f2a9f7fc
commit 0de3f4be7d
42 changed files with 4297 additions and 1586 deletions
@@ -7,6 +7,15 @@ import { apiFetch, fetchAllUsers, fetchAllPayments } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { scoreUser } from "@/lib/fuzzyMatch";
// A donation is never mutated once created — assigning it to a registration creates a separate
// "leg" Payment row (isDonation:false, originalPaymentId -> the donation, amount > 0) instead.
// That leg is not new money: it just re-labels part of an already-counted donation as applied
// to a registration. Money stats/lists must count each real inflow exactly once, so legs are
// excluded — the money was already counted via the original donation row.
function isDonationLeg(p: any): boolean {
return !p?.isDonation && !!p?.originalPaymentId && (p?.amount || 0) > 0;
}
function RegistrationOptions({ regs, regOutstanding }: {
regs: any[];
regOutstanding: Record<string, { totalDue: number; totalPaid: number; outstanding: number }>;
@@ -439,7 +448,8 @@ function PaymentsContent() {
// Stats
const todayTotals = useMemo(() => {
const start = new Date(); start.setHours(0,0,0,0);
const today = payments.filter(p => new Date(p.createdAt).getTime() >= start.getTime());
// Exclude donation-application legs — that money was already counted once, as the donation.
const today = payments.filter(p => new Date(p.createdAt).getTime() >= start.getTime() && !isDonationLeg(p));
const revenue = today.reduce((s,p)=> s + (p.amount||0), 0);
const donations = today.filter(p => p.isDonation).length;
return { revenue, donations, count: today.length };
@@ -805,7 +815,7 @@ function PaymentsContent() {
{loadingList && <span className="text-xs text-gray-500">Loading</span>}
</div>
<ul className="text-sm space-y-2 max-h-[520px] overflow-auto pr-2">
{payments.slice(0, 25).map(p => {
{payments.filter(p => !isDonationLeg(p)).slice(0, 25).map(p => {
const amt = p.amount || 0;
const isRefund = amt < 0;
return (
@@ -818,6 +828,9 @@ function PaymentsContent() {
{(p.registration?.user?.name || p.user?.name) && <div className="text-xs text-gray-600">Name: {p.registration?.user?.name || p.user?.name}</div>}
{p.registrationId && <div className="text-xs text-gray-600">Registration: #{String(p.registrationId).slice(0,8)}</div>}
{p.eventId && <div className="text-xs text-gray-600">Event: {p.event?.title || p.eventId}</div>}
{p.recordedBy?.name && String(p.recordedBy.id) !== String(p.userId) && (
<div className="text-xs text-gray-500">Recorded by: {p.recordedBy.name}</div>
)}
</li>
);
})}
@@ -1025,27 +1038,50 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
[registrations, registrationId]
);
// Donations "relevant to that event" — unassigned donations logged against the same event
// as the chosen registration.
// A donation is never mutated once assigned — each assignment creates a separate "leg"
// Payment row (isDonation:false, originalPaymentId -> the donation). A donation's remaining
// balance is its original amount minus every leg that references it, so it stays offerable
// (and its registrationId stays null forever) until fully used up.
const legsById = useMemo(() => {
const m = new Map<string, number>();
payments.forEach((p: any) => {
if (p.originalPaymentId && !p.isDonation) {
m.set(p.originalPaymentId, (m.get(p.originalPaymentId) || 0) + (p.amount || 0));
}
});
return m;
}, [payments]);
// Donations "relevant to that event" — donations logged against the same event as the
// chosen registration that still have a remaining, unused balance.
const donationsForEvent = useMemo(() => {
if (!selectedRegistration) return [] as any[];
const eventId = selectedRegistration.eventId || selectedRegistration.event?.id;
return payments.filter((p: any) => p.isDonation && !p.registrationId && String(p.eventId) === String(eventId));
}, [payments, selectedRegistration]);
return payments.filter((p: any) => {
if (!p.isDonation || p.registrationId) return false;
if (String(p.eventId) !== String(eventId)) return false;
const remaining = (p.amount || 0) - (legsById.get(p.id) || 0);
return remaining > 0.000001;
});
}, [payments, selectedRegistration, legsById]);
const selectedDonation = useMemo(
() => payments.find((p: any) => String(p.id) === String(paymentId)),
[payments, paymentId]
);
const donationRemaining = selectedDonation
? (selectedDonation.amount || 0) - (legsById.get(selectedDonation.id) || 0)
: 0;
const outstanding = registrationId ? (regOutstanding[registrationId]?.outstanding ?? 0) : 0;
// The most that can be allocated: never more than the donation itself, never more than
// what's actually owed. Staff can type a smaller amount to leave a balance outstanding.
// The most that can be allocated: never more than the donation's remaining balance, never
// more than what's actually owed. Staff can type a smaller amount to leave a balance owing.
const maxAllocatable = useMemo(() => {
if (!selectedDonation) return 0;
return Math.min(selectedDonation.amount || 0, outstanding);
}, [selectedDonation, outstanding]);
return Math.min(donationRemaining, outstanding);
}, [selectedDonation, donationRemaining, outstanding]);
// Default to "apply as much as needed" whenever a new donation is picked — the common
// case needs no typing, but the field stays editable for a deliberate partial allocation.
@@ -1055,7 +1091,7 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
}, [paymentId]);
const amountNum = parseFloat(amountStr || "0");
const leftover = selectedDonation ? Math.max(0, (selectedDonation.amount || 0) - amountNum) : 0;
const leftover = selectedDonation ? Math.max(0, donationRemaining - amountNum) : 0;
const assign = async () => {
if (!token) return;
@@ -1110,12 +1146,13 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
>
<option value="">Select donation</option>
{donationsForEvent.map((p: any) => {
const label = `R ${(p.amount || 0).toFixed(2)}${p.user?.name || p.userId || 'Donor'} — #${String(p.id).slice(0,8)}`;
const remaining = (p.amount || 0) - (legsById.get(p.id) || 0);
const label = `R ${remaining.toFixed(2)} of R ${(p.amount || 0).toFixed(2)} left — ${p.user?.name || p.userId || 'Donor'} — #${String(p.id).slice(0,8)}`;
return <option key={p.id} value={p.id} title={label}>{label}</option>;
})}
</select>
{registrationId && donationsForEvent.length === 0 && (
<div className="text-xs text-gray-500">No unassigned donations for this event.</div>
<div className="text-xs text-gray-500">No donations with a remaining balance for this event.</div>
)}
{selectedDonation && (
@@ -1131,8 +1168,8 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
onChange={e => setAmountStr(e.target.value)}
/>
<div className="text-xs text-gray-500">
Donation is R {(selectedDonation.amount || 0).toFixed(2)}; outstanding balance is R {outstanding.toFixed(2)}.
{leftover > 0.000001 && <> The remaining R {leftover.toFixed(2)} will stay unassigned as a donation.</>}
Donation has R {donationRemaining.toFixed(2)} remaining (of R {(selectedDonation.amount || 0).toFixed(2)} total); outstanding balance is R {outstanding.toFixed(2)}.
{leftover > 0.000001 && <> The remaining R {leftover.toFixed(2)} will stay available on this donation for future assignments.</>}
</div>
</>
)}