Speed up payment/refund processing on the supervisor dashboard
Reconciling a Yoco payment and sending payment links blocked the HTTP response on ticket-PDF generation and email/WhatsApp sends; they now run in the background like the other payment flows already did. Registration/payment option loops (pricing, stock checks, ticket generation) now resolve concurrently instead of sequentially. The Payments page dropped a per-registration N+1 fetch and now refreshes its lists in parallel after each action. Added missing indexes for dashboard stats and donation-leg lookups, and made GET /api/registrations optionally paginated. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -187,42 +187,24 @@ function PaymentsContent() {
|
||||
return (price >= 0) ? price : base;
|
||||
};
|
||||
|
||||
// Load registrations for dropdowns
|
||||
// Load registrations for dropdowns.
|
||||
// GET /api/registrations already embeds each registration's `payments`, so outstanding
|
||||
// balances are computed from that in one pass — no per-registration follow-up requests.
|
||||
const loadRegistrations = async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
setLoadingRegs(true);
|
||||
const regs = await apiFetch<any[]>("/api/registrations", { authToken: token });
|
||||
const list = Array.isArray(regs) ? regs : [];
|
||||
// Compute initial totalDue using priceSnapshot (authoritative backend price)
|
||||
const now = new Date();
|
||||
const baseMap: Record<string, { totalDue: number; totalPaid: number; outstanding: number }> = {};
|
||||
const map: Record<string, { totalDue: number; totalPaid: number; outstanding: number }> = {};
|
||||
for (const r of list) {
|
||||
// totalDue uses priceSnapshot — not time-dependent
|
||||
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
|
||||
baseMap[r.id] = { totalDue, totalPaid: 0, outstanding: totalDue };
|
||||
const totalPaid = (r.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0);
|
||||
map[r.id] = { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid) };
|
||||
}
|
||||
setRegOutstanding(baseMap);
|
||||
// Fetch payments per registration to compute outstanding
|
||||
await Promise.all(
|
||||
list.map(async (r: any) => {
|
||||
try {
|
||||
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(r.id)}`, { authToken: token });
|
||||
const totalPaid = (pays || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
// totalDue uses priceSnapshot — not time-dependent
|
||||
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
|
||||
setRegOutstanding(prev => ({
|
||||
...prev,
|
||||
[r.id]: {
|
||||
totalDue,
|
||||
totalPaid,
|
||||
outstanding: Math.max(0, totalDue - totalPaid)
|
||||
}
|
||||
}));
|
||||
} catch (e) {
|
||||
// ignore per-reg errors
|
||||
}
|
||||
})
|
||||
);
|
||||
setRegOutstanding(map);
|
||||
// Sort by createdAt desc
|
||||
const sorted = list.sort((a,b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
setRegistrations(sorted);
|
||||
@@ -449,8 +431,7 @@ function PaymentsContent() {
|
||||
});
|
||||
setInfo(`Payment created (R ${amt.toFixed(2)})`);
|
||||
setAmount(""); setRegistrationId(""); setIsDonation(false); setEventId(""); setPaidAtLocal("");
|
||||
await loadPayments();
|
||||
await loadRegistrations();
|
||||
await Promise.all([loadPayments(), loadRegistrations()]);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to create payment");
|
||||
} finally {
|
||||
@@ -532,9 +513,7 @@ function PaymentsContent() {
|
||||
setInfo('Reconciled as donation');
|
||||
}
|
||||
setReconcileForm({ txId: null, type: null, userId: '', registrationId: '', eventId: '', submitting: false });
|
||||
await loadYocoUnreconciled();
|
||||
await loadPayments();
|
||||
await loadRegistrations();
|
||||
await Promise.all([loadYocoUnreconciled(), loadPayments(), loadRegistrations()]);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to reconcile');
|
||||
} finally {
|
||||
@@ -776,7 +755,7 @@ function PaymentsContent() {
|
||||
usersList={usersList}
|
||||
regsForUser={(uid:string)=> registrations.filter((r:any)=> String(r.user?.id||r.userId)===String(uid))}
|
||||
regOutstanding={regOutstanding}
|
||||
onDone={async()=>{ await loadPayments(); await loadRegistrations(); setInfo('Refund recorded'); }}
|
||||
onDone={async()=>{ await Promise.all([loadPayments(), loadRegistrations()]); setInfo('Refund recorded'); }}
|
||||
/>
|
||||
{loadingUsers && <div className="text-xs text-gray-500">Loading users…</div>}
|
||||
</>
|
||||
@@ -789,7 +768,7 @@ function PaymentsContent() {
|
||||
allUsers={allUsers}
|
||||
registrations={registrations}
|
||||
regOutstanding={regOutstanding}
|
||||
onDone={async()=>{ await loadPayments(); await loadRegistrations(); setInfo('Donation assigned to registration'); }}
|
||||
onDone={async()=>{ await Promise.all([loadPayments(), loadRegistrations()]); setInfo('Donation assigned to registration'); }}
|
||||
/>
|
||||
{loadingUsers && <div className="text-xs text-gray-500">Loading users…</div>}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user