Files
hope-events/frontend/src/app/dashboard/supervisor/payments/page.tsx
T
joshua 8f69e58aa2 Add tab icons and fix mobile dashboard styling issues
- Add matching lucide-react icons to tab/mode switcher buttons on
  payments, at-the-door, manual, email-attendees, whatsapp-attendees,
  and admin cashup pages, mirroring icons already used in their help menus
- Fix navbar Logout button sitting lower than other nav links (missing
  border/padding classes that other links use for their active-underline)
- Fix stat card labels getting truncated on mobile by removing the
  ellipsis-cut label and widening the mobile grid to one column
- Fix Revenue trend / Top performing events rendering outside the
  viewport on mobile by containing horizontal overflow and truncating
  long event titles in the table
2026-08-07 00:41:52 +02:00

1233 lines
61 KiB
TypeScript

"use client";
import React, { Suspense, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers, fetchAllPayments } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { scoreUser } from "@/lib/fuzzyMatch";
import { Wallet, RotateCcw, HandHeart, ScanLine, Link2 } from "lucide-react";
// 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;
}
// Refund methods must mirror the real payment methods (cash/card/eft/voucher) so a refund
// nets against the same bucket its original payment counted under in reports — a card payment
// refunded as "cash-refund" would wrongly drain the cash float and leave card overstated.
function refundMethodForOriginal(method: string | null | undefined): string {
const m = String(method || "").toLowerCase();
if (m.includes("cash")) return "cash-refund";
if (m.includes("eft")) return "eft-refund";
if (m.includes("voucher")) return "voucher-refund";
if (m.includes("card") || m.includes("yoco") || m.includes("pay")) return "card-refund";
return "";
}
function RegistrationOptions({ regs, regOutstanding }: {
regs: any[];
regOutstanding: Record<string, { totalDue: number; totalPaid: number; outstanding: number }>;
}) {
return (
<>
{regs.map((r: any) => {
const out = regOutstanding[r.id]?.outstanding ?? 0;
const label = `${r.event?.title || r.eventId || 'Event'}${r.user?.name || r.userId || 'User'} — Outstanding: R ${out.toFixed(2)} — #${String(r.id).slice(0,8)}`;
return (
<option key={r.id} value={r.id} title={label}>
{label}
</option>
);
})}
</>
);
}
function UserSearchField({ allUsers, value, onChange, placeholder = "Search by name, email or phone…", disabled = false }: {
allUsers: any[];
value: string;
onChange: (userId: string) => void;
placeholder?: string;
disabled?: boolean;
}) {
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
// Sync display name when value changes externally (e.g. pre-fill from metadata)
useEffect(() => {
const u = allUsers.find(u => String(u.id) === String(value));
setQuery(u?.name || "");
}, [value, allUsers]);
const matches = useMemo(() => {
if (query.trim().length < 2) return [];
return allUsers
.map(u => ({ u, score: scoreUser(u, query) }))
.filter(x => x.score >= 0.45)
.sort((a, b) => b.score - a.score)
.slice(0, 8)
.map(x => x.u);
}, [query, allUsers]);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
return (
<div ref={ref} className="relative">
<input
className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400 disabled:bg-gray-50 pr-7"
placeholder={placeholder}
value={query}
disabled={disabled}
autoComplete="off"
onChange={e => { setQuery(e.target.value); setOpen(true); if (!e.target.value) onChange(""); }}
onFocus={() => { if (query.length >= 2) setOpen(true); }}
/>
{value && (
<button type="button" className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 text-xs" onClick={() => { onChange(""); setQuery(""); setOpen(false); }}></button>
)}
{open && query.trim().length >= 2 && (
<div className="absolute z-30 top-full left-0 right-0 mt-1 bg-white border border-gray-200 rounded-xl shadow-lg overflow-hidden">
{matches.length > 0 ? (
<>
<div className="px-3 py-1.5 text-[11px] text-gray-400 bg-gray-50 border-b">{matches.length} result{matches.length !== 1 ? "s" : ""}</div>
{matches.map(u => (
<button key={u.id} type="button" className="w-full text-left px-3 py-2.5 hover:bg-brand-50 border-b border-gray-100 last:border-b-0 transition-colors" onClick={() => { onChange(String(u.id)); setQuery(u.name || ""); setOpen(false); }}>
<div className="text-sm font-medium text-gray-900">{u.name}</div>
<div className="text-xs text-gray-500 mt-0.5 flex gap-2 flex-wrap">
{u.email && <span>{u.email}</span>}
{u.phoneNumber && <span>· {u.phoneNumber}</span>}
</div>
</button>
))}
</>
) : (
<div className="px-3 py-3 text-sm text-gray-500 italic">No matching users found.</div>
)}
</div>
)}
</div>
);
}
function PaymentsContent() {
const { user, loading, token } = useAuth();
const router = useRouter();
const search = useSearchParams();
const canView = useMemo(() => {
const role = user?.role;
return role === "admin" || role === "supervisor";
}, [user]);
useEffect(() => {
if (loading) return;
if (!user) router.replace("/login");
}, [user, loading, router]);
const [payments, setPayments] = useState<any[]>([]);
const [loadingList, setLoadingList] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const [registrations, setRegistrations] = useState<any[]>([]);
const [loadingRegs, setLoadingRegs] = useState(false);
const [regOutstanding, setRegOutstanding] = useState<Record<string, { totalDue: number; totalPaid: number; outstanding: number }>>({});
const loadPayments = async () => {
if (!token) return;
try {
setLoadingList(true);
const list = await fetchAllPayments(token);
setPayments(list.sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()));
} catch (e: any) {
setError(e?.message || "Failed to load payments");
} finally {
setLoadingList(false);
}
};
useEffect(() => { loadPayments(); }, [token]);
// Compute effective unit price for a registration option.
// Uses priceSnapshot when available (authoritative backend price, variant-aware).
// Falls back to deadline-only early-bird calculation for legacy rows without a snapshot.
const optionUnitPrice = (opt: any, referenceTime: any, atTime: Date): number => {
if (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) {
return Number(opt.priceSnapshot);
}
const eo = opt.eventOption;
const variantId: string | null = opt.variantId || null;
const base = (opt.variant?.price !== null && opt.variant?.price !== undefined)
? Number(opt.variant.price)
: Number(eo?.price || 0);
const allTiers = Array.isArray(eo?.earlyBirdTiers) ? eo.earlyBirdTiers.slice() : [];
const tiers = variantId
? allTiers.filter((t: any) => t.variantId === variantId)
: allTiers.filter((t: any) => !t.variantId);
if (tiers.length === 0) return base;
const t = atTime ? new Date(atTime) : new Date();
const ref = referenceTime ? new Date(referenceTime) : t;
const applicable = tiers
.map((x: any) => ({ ...x, deadline: new Date(x.deadline) }))
.filter((x: any) => (ref < x.deadline) && (t < x.deadline))
.sort((a: any, b: any) => a.deadline.getTime() - b.deadline.getTime() || (a.order||0) - (b.order||0) || a.price - b.price);
if (applicable.length === 0) return base;
const price = Number(applicable[0].price);
return (price >= 0) ? price : base;
};
// Load registrations for dropdowns
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 }> = {};
for (const r of list) {
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 };
}
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
}
})
);
// Sort by createdAt desc
const sorted = list.sort((a,b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
setRegistrations(sorted);
} catch (e) {
// ignore
} finally {
setLoadingRegs(false);
}
};
useEffect(() => { loadRegistrations(); }, [token]);
// Load all users for Refund section
useEffect(() => {
(async () => {
if (!token) return;
try {
setLoadingUsers(true);
const users = await fetchAllUsers(token);
setAllUsers(users);
} catch (e) {
// ignore; fallback to derived users from registrations
} finally {
setLoadingUsers(false);
}
})();
}, [token]);
// Create payment form
const [amount, setAmount] = useState<string>("");
const [method, setMethod] = useState<string>("cash");
const [registrationId, setRegistrationId] = useState<string>("");
const [isDonation, setIsDonation] = useState<boolean>(false);
const [eventId, setEventId] = useState<string>("");
const [events, setEvents] = useState<any[]>([]);
const [submitting, setSubmitting] = useState(false);
// Backdate field
const [paidAtLocal, setPaidAtLocal] = useState<string>("");
// Users for Refund section (load all users with supervisor rights)
const [allUsers, setAllUsers] = useState<any[]>([]);
const [loadingUsers, setLoadingUsers] = useState(false);
// New: user selection states for split dropdowns
const [selectedUserId, setSelectedUserId] = useState<string>("");
// Derived lists for users and outstanding registrations
const outstandingRegs = useMemo(() => {
return registrations.filter((r: any) => (r.status !== 'paid') && (r.status !== 'cancelled') && ((regOutstanding[r.id]?.outstanding ?? 0) > 0));
}, [registrations, regOutstanding]);
// Users list for UI dropdowns (use all users if loaded; fallback to outstanding registrations)
const usersList = useMemo(() => {
if (allUsers && allUsers.length > 0) {
return [...allUsers]
//.filter(u => u?.isActive !== false)
.map(u => ({ id: String(u.id), name: String(u.name || u.email || u.id) }))
.sort((a, b) => a.name.localeCompare(b.name, undefined as any, { sensitivity: 'base' } as any));
}
const map = new Map<string, string>();
for (const r of outstandingRegs) {
const uid = r.user?.id || r.userId;
if (!uid) continue;
const name = r.user?.name || r.user?.email || String(r.userId || uid);
if (!map.has(String(uid))) {
map.set(String(uid), String(name));
}
}
return Array.from(map.entries())
.map(([id, name]) => ({ id, name }))
.sort((a, b) => a.name.localeCompare(b.name, undefined as any, { sensitivity: 'base' } as any));
}, [allUsers, outstandingRegs]);
const regsForSelectedUser = useMemo(() => {
return outstandingRegs.filter((r: any) => (String(r.user?.id || r.userId) === String(selectedUserId)));
}, [outstandingRegs, selectedUserId]);
// Payment link tab state
const [linkUserId, setLinkUserId] = useState<string>("");
const [linkRegistrationId, setLinkRegistrationId] = useState<string>("");
const [linkAmount, setLinkAmount] = useState<string>("");
const [linkGenerating, setLinkGenerating] = useState(false);
const [linkResult, setLinkResult] = useState<{ redirectUrl: string; amount: number; registrationId: string } | null>(null);
const [linkSending, setLinkSending] = useState<"email" | "whatsapp" | null>(null);
const [linkCopied, setLinkCopied] = useState(false);
const regsForLinkUser = useMemo(() => {
return outstandingRegs.filter((r: any) => (String(r.user?.id || r.userId) === String(linkUserId)));
}, [outstandingRegs, linkUserId]);
useEffect(() => {
if (!linkRegistrationId) return;
const out = regOutstanding[linkRegistrationId]?.outstanding ?? 0;
setLinkAmount(out > 0 ? out.toFixed(2) : "");
setLinkResult(null);
}, [linkRegistrationId, regOutstanding]);
const generatePaymentLink = async () => {
if (!token || !linkRegistrationId) return;
setError(null); setInfo(null); setLinkCopied(false);
const amt = parseFloat(linkAmount || "0");
if (!amt || amt <= 0) { setError("Enter a valid amount"); return; }
try {
setLinkGenerating(true);
const res = await apiFetch<{ redirectUrl?: string; checkoutId?: string; amount?: number; priceUpdated?: boolean; message?: string }>("/api/payments/yoco-checkout", {
method: "POST",
authToken: token,
body: {
registrationId: linkRegistrationId,
amount: amt,
successUrl: window.location.origin + "/payment/success",
cancelUrl: window.location.origin + "/payment/cancel",
failureUrl: window.location.origin + "/payment/failure",
},
});
if (res.priceUpdated) {
setError(res.message || "Pricing has changed for this registration; please re-check the amount.");
return;
}
if (!res.redirectUrl) { setError("Failed to generate link"); return; }
setLinkResult({ redirectUrl: res.redirectUrl, amount: res.amount ?? amt, registrationId: linkRegistrationId });
setInfo("Payment link generated");
} catch (e: any) {
setError(e?.message || "Failed to generate payment link");
} finally {
setLinkGenerating(false);
}
};
const copyPaymentLink = async () => {
if (!linkResult) return;
try {
await navigator.clipboard.writeText(linkResult.redirectUrl);
setLinkCopied(true);
setTimeout(() => setLinkCopied(false), 2000);
} catch {
setError("Could not copy link — copy it manually");
}
};
const sendPaymentLink = async (channel: "email" | "whatsapp") => {
if (!token || !linkResult) return;
setError(null); setInfo(null);
try {
setLinkSending(channel);
await apiFetch("/api/payments/yoco-checkout/send", {
method: "POST",
authToken: token,
body: { registrationId: linkResult.registrationId, redirectUrl: linkResult.redirectUrl, channel },
});
setInfo(`Payment link sent via ${channel === "email" ? "email" : "WhatsApp"}`);
} catch (e: any) {
setError(e?.message || `Failed to send link via ${channel}`);
} finally {
setLinkSending(null);
}
};
useEffect(() => {
const regQ = search?.get("registrationId");
if (regQ) setRegistrationId(regQ);
}, [search]);
// If a registration is preselected via query, infer and set its user
useEffect(() => {
if (!registrationId) return;
const reg = registrations.find((r: any) => String(r.id) === String(registrationId));
const uid = reg?.user?.id || reg?.userId;
if (uid) setSelectedUserId(String(uid));
}, [registrationId, registrations]);
const [eventsIncludePast, setEventsIncludePast] = useState(false);
const [eventsIncludeInactive, setEventsIncludeInactive] = useState(false);
useEffect(() => {
(async () => {
try {
if (!token) return;
const params = new URLSearchParams();
if (eventsIncludePast) params.set("includePast", "true");
if (eventsIncludeInactive) params.set("includeInactive", "true");
const qs = params.toString() ? `?${params.toString()}` : "";
const evs = await apiFetch<any[]>(`/api/events/all${qs}`, { authToken: token });
setEvents(evs || []);
} catch {}
})();
}, [token, eventsIncludePast, eventsIncludeInactive]);
useEffect(() => {
// Keep inputs sensible when toggling donation
if (isDonation) {
setRegistrationId("");
} else {
setEventId("");
}
}, [isDonation]);
const createPayment = async () => {
if (!token) return;
setError(null); setInfo(null);
const amt = parseFloat(amount || "0");
if (!amt || amt <= 0) { setError("Enter a valid amount"); return; }
if (!registrationId && !isDonation) {
setError("Registration required unless this is a donation");
return;
}
if (isDonation && !eventId && !registrationId) {
setError("Select an event for donations");
return;
}
try {
setSubmitting(true);
const res = await apiFetch<any>("/api/payments", {
method: "POST",
authToken: token,
body: {
amount: amt,
method,
userId: selectedUserId || undefined,
registrationId: registrationId || undefined,
eventId: eventId || undefined,
isDonation: !!isDonation,
paidAt: paidAtLocal ? new Date(paidAtLocal).toISOString() : undefined,
}
});
setInfo(`Payment created (R ${amt.toFixed(2)})`);
setAmount(""); setRegistrationId(""); setIsDonation(false); setEventId(""); setPaidAtLocal("");
await loadPayments();
await loadRegistrations();
} catch (e: any) {
setError(e?.message || "Failed to create payment");
} finally {
setSubmitting(false);
}
};
// Stats
const todayTotals = useMemo(() => {
const start = new Date(); start.setHours(0,0,0,0);
// 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 };
}, [payments]);
const [mode, setMode] = useState<'payment'|'refund'|'donation'|'reconcile'|'link'>("payment");
// Yoco unreconciled list and actions
const [yocoLoading, setYocoLoading] = useState(false);
const [yocoTxs, setYocoTxs] = useState<any[]>([]);
const loadYocoUnreconciled = async () => {
if (!token) return;
try {
setYocoLoading(true);
const res = await apiFetch<any>("/api/yoco-transactions/unreconciled", { authToken: token });
const items = Array.isArray(res?.data) ? res.data : (Array.isArray(res) ? res : []);
setYocoTxs(items);
} catch (e) {
// ignore silently, shown on demand via error state if needed
} finally {
setYocoLoading(false);
}
};
useEffect(() => { loadYocoUnreconciled(); }, [token]);
// Inline reconcile form state per-row
const [reconcileForm, setReconcileForm] = useState<{ txId: string | null; type: 'reg' | 'don' | null; userId: string; registrationId: string; eventId: string; submitting: boolean }>({ txId: null, type: null, userId: '', registrationId: '', eventId: '', submitting: false });
const openReconcileToRegistration = (tx: any) => {
const metaUser = tx?.raw?.payload?.metadata?.userId ? String(tx.raw.payload.metadata.userId) : '';
setReconcileForm({ txId: tx.id, type: 'reg', userId: metaUser, registrationId: '', eventId: '', submitting: false });
};
const openReconcileAsDonation = (tx: any) => {
const metaEvent = tx?.raw?.payload?.metadata?.eventId ? String(tx.raw.payload.metadata.eventId) : '';
setReconcileForm({ txId: tx.id, type: 'don', userId: '', registrationId: '', eventId: metaEvent, submitting: false });
};
const regsForUser = (uid: string) => registrations
.filter((r: any) => String(r.user?.id || r.userId) === String(uid))
.filter((r: any) => {
const out = regOutstanding[r.id]?.outstanding ?? 0;
return out > 0.000001; // show only registrations with an outstanding balance
});
const submitReconcile = async () => {
if (!token) return;
const { txId, type, userId, registrationId, eventId } = reconcileForm;
if (!txId || !type) return;
try {
setReconcileForm(prev => ({ ...prev, submitting: true }));
if (type === 'reg') {
if (!registrationId) { setError('Please select a registration'); return; }
await apiFetch(`/api/yoco-transactions/${encodeURIComponent(txId)}/reconcile`, {
method: 'POST',
authToken: token,
body: { registrationId }
});
setInfo('Reconciled to registration');
} else {
if (!eventId) { setError('Please select an event'); return; }
await apiFetch(`/api/yoco-transactions/${encodeURIComponent(txId)}/reconcile`, {
method: 'POST',
authToken: token,
body: { eventId, userId: userId || undefined }
});
setInfo('Reconciled as donation');
}
setReconcileForm({ txId: null, type: null, userId: '', registrationId: '', eventId: '', submitting: false });
await loadYocoUnreconciled();
await loadPayments();
await loadRegistrations();
} catch (e: any) {
setError(e?.message || 'Failed to reconcile');
} finally {
setReconcileForm(prev => ({ ...prev, submitting: false }));
}
};
const cancelReconcile = () => setReconcileForm({ txId: null, type: null, userId: '', registrationId: '', eventId: '', submitting: false });
return (
<div className="max-w-6xl mx-auto w-full p-4 sm:p-6 overflow-x-hidden">
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<Wallet className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">Payments</h1>
</div>
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
</div>
{!canView && (
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
You need supervisor or admin access to use this page.
</div>
)}
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
{info && <div className="p-3 mb-3 border rounded bg-emerald-50 text-emerald-800 text-sm">{info}</div>}
<div className="mb-4 flex flex-wrap items-center gap-2">
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${mode === 'payment' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="mode" value="payment" className="hidden" checked={mode==='payment'} onChange={() => setMode('payment')} />
<Wallet className="w-4 h-4" />
Payment
</label>
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${mode === 'refund' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="mode" value="refund" className="hidden" checked={mode==='refund'} onChange={() => setMode('refund')} />
<RotateCcw className="w-4 h-4" />
Refund
</label>
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${mode === 'donation' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="mode" value="donation" className="hidden" checked={mode==='donation'} onChange={() => setMode('donation')} />
<HandHeart className="w-4 h-4" />
Donations
</label>
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${mode === 'reconcile' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="mode" value="reconcile" className="hidden" checked={mode==='reconcile'} onChange={() => setMode('reconcile')} />
<ScanLine className="w-4 h-4" />
Reconcile
</label>
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${mode === 'link' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="mode" value="link" className="hidden" checked={mode==='link'} onChange={() => setMode('link')} />
<Link2 className="w-4 h-4" />
Payment Link
</label>
</div>
<div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
{/* Yoco reconciliation panel */}
{mode === 'reconcile' && (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex items-center justify-between mb-3">
<div className="text-lg font-semibold">Unreconciled Yoco Payments</div>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800" onClick={loadYocoUnreconciled} disabled={yocoLoading}>{yocoLoading ? 'Refreshing…' : 'Refresh'}</button>
</div>
{yocoTxs.length === 0 ? (
<div className="text-sm text-gray-500">No unreconciled Yoco transactions.</div>
) : (
<div className="overflow-x-auto w-full">
<table className="min-w-full text-sm">
<thead>
<tr className="text-left border-b">
<th className="py-2 pr-3">Created</th>
<th className="py-2 pr-3">External ID</th>
<th className="py-2 pr-3">Amount</th>
<th className="py-2 pr-3">Checkout</th>
<th className="py-2 pr-3">Method</th>
<th className="py-2">Actions</th>
</tr>
</thead>
<tbody>
{yocoTxs.map(tx => (
<React.Fragment key={tx.id}>
<tr className="border-b hover:bg-gray-50">
<td className="py-2 pr-3 whitespace-nowrap">{tx.createdDate ? new Date(tx.createdDate).toLocaleString() : '-'}</td>
<td className="py-2 pr-3 font-mono text-xs break-all max-w-[10rem]">{tx.externalId}</td>
<td className="py-2 pr-3">R {(Number(tx.amount || 0)/100).toFixed(2)}</td>
<td className="py-2 pr-3 break-all max-w-[8rem]">{tx.checkoutId || '-'}</td>
<td className="py-2 pr-3">{tx.methodType || '-'}</td>
<td className="py-2 flex flex-wrap gap-2">
<button className="px-2 py-1 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={() => openReconcileToRegistration(tx)}>To Registration</button>
<button className="px-2 py-1 rounded bg-emerald-600 text-white hover:bg-emerald-700" onClick={() => openReconcileAsDonation(tx)}>As Donation</button>
<button className="px-2 py-1 rounded bg-gray-600 text-white hover:bg-gray-700" onClick={async () => {
if (!token) return;
const ok = window.confirm('Ignore this transaction? It will be marked as ignored and reconciled.');
if (!ok) return;
try {
await apiFetch(`/api/yoco-transactions/${encodeURIComponent(tx.id)}/ignore`, { method: 'POST', authToken: token });
setInfo('Transaction ignored');
await loadYocoUnreconciled();
} catch (e:any) {
setError(e?.message || 'Failed to ignore');
}
}}>Ignore</button>
</td>
</tr>
{reconcileForm.txId === tx.id && (
<tr className="bg-gray-50">
<td colSpan={6} className="p-3">
{reconcileForm.type === 'reg' ? (
<div className="grid sm:grid-cols-3 gap-3 items-end">
<div>
<label className="block text-xs text-gray-600 mb-1">User</label>
<UserSearchField allUsers={allUsers} value={reconcileForm.userId} onChange={uid => setReconcileForm(prev => ({ ...prev, userId: uid, registrationId: '' }))} />
</div>
<div className="sm:col-span-2">
<label className="block text-xs text-gray-600 mb-1">Registration</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={reconcileForm.registrationId} onChange={e => setReconcileForm(prev => ({ ...prev, registrationId: e.target.value }))} disabled={!reconcileForm.userId}>
<option value="">Select registration</option>
{reconcileForm.userId && regsForUser(reconcileForm.userId).map((r: any) => {
const out = regOutstanding[r.id]?.outstanding ?? 0;
const label = `${r.event?.title || r.eventId || 'Event'} — Outstanding: R ${out.toFixed(2)} — #${String(r.id).slice(0,8)}`;
return <option key={r.id} value={r.id} title={label}>{label}</option>;
})}
{reconcileForm.userId && regsForUser(reconcileForm.userId).length === 0 && (
<option value="" disabled>No registrations with outstanding balance</option>
)}
</select>
</div>
<div className="flex gap-2">
<button className="px-3 py-1.5 rounded bg-brand-600 text-white text-sm disabled:opacity-50" disabled={!reconcileForm.registrationId || reconcileForm.submitting} onClick={submitReconcile}>{reconcileForm.submitting ? 'Saving…' : 'Confirm'}</button>
<button className="px-3 py-1.5 rounded bg-gray-200 text-gray-800 text-sm" onClick={cancelReconcile}>Cancel</button>
</div>
</div>
) : (
<div className="grid sm:grid-cols-3 gap-3 items-end">
<div>
<label className="block text-xs text-gray-600 mb-1">From (donor, optional)</label>
<UserSearchField allUsers={allUsers} value={reconcileForm.userId} onChange={uid => setReconcileForm(prev => ({ ...prev, userId: uid }))} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={reconcileForm.eventId} onChange={e => setReconcileForm(prev => ({ ...prev, eventId: e.target.value }))}>
<option value="">Select event</option>
{events.map(ev => (<option key={ev.id} value={ev.id}>{ev.title}</option>))}
</select>
</div>
<div className="flex gap-2">
<button className="px-3 py-1.5 rounded bg-emerald-600 text-white text-sm disabled:opacity-50" disabled={!reconcileForm.eventId || reconcileForm.submitting} onClick={submitReconcile}>{reconcileForm.submitting ? 'Saving…' : 'Confirm'}</button>
<button className="px-3 py-1.5 rounded bg-gray-200 text-gray-800 text-sm" onClick={cancelReconcile}>Cancel</button>
</div>
</div>
)}
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{mode === 'payment' && (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="text-lg font-semibold mb-3">Create payment</div>
<div className="grid sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Amount</label>
<input className="w-full border rounded px-3 py-2 text-sm" type="number" step="0.01" value={amount} onChange={e => setAmount(e.target.value)} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Method</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={method} onChange={e => setMethod(e.target.value)}>
<option value="cash">Cash</option>
<option value="card">Card</option>
<option value="eft">EFT</option>
<option value="voucher">Voucher</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">User</label>
<UserSearchField allUsers={allUsers} value={selectedUserId} onChange={uid => { setSelectedUserId(uid); setRegistrationId(""); }} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Registration (required unless donation)</label>
<select className="w-full max-w-full border rounded px-3 py-2 text-sm truncate" value={registrationId} onChange={e => setRegistrationId(e.target.value)} disabled={isDonation || !selectedUserId}>
<option value="">Select registration</option>
<RegistrationOptions regs={regsForSelectedUser} regOutstanding={regOutstanding} />
</select>
{loadingRegs && <div className="text-xs text-gray-500 mt-1">Loading registrations</div>}
</div>
<div className="flex items-center gap-2">
<input id="isDonation" type="checkbox" checked={isDonation} onChange={e => setIsDonation(e.target.checked)} />
<label htmlFor="isDonation" className="text-sm">Donation</label>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Event (for donations)</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={eventId} onChange={e => setEventId(e.target.value)} disabled={!isDonation}>
<option value="">Select event</option>
{events.map(ev => (<option key={ev.id} value={ev.id}>{ev.title}</option>))}
</select>
<div className="flex items-center gap-4 mt-1.5 text-xs text-gray-500">
<label className="flex items-center gap-1 cursor-pointer">
<input type="checkbox" checked={eventsIncludePast} onChange={e => setEventsIncludePast(e.target.checked)} />
Include past events
</label>
{user?.role === "admin" && (
<label className="flex items-center gap-1 cursor-pointer">
<input type="checkbox" checked={eventsIncludeInactive} onChange={e => setEventsIncludeInactive(e.target.checked)} />
Include inactive events
</label>
)}
</div>
</div>
<div className="sm:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-3 mt-2">
<div>
<label className="block text-xs text-gray-600 mb-1">Paid at (optional)</label>
<input className="w-full border rounded px-3 py-2 text-sm" type="datetime-local" value={paidAtLocal} onChange={e=>setPaidAtLocal(e.target.value)} max={new Date().toISOString().slice(0,16)} />
<div className="text-[10px] text-gray-500 mt-1">Leave blank to use current time</div>
</div>
</div>
</div>
<div className="mt-3">
<button disabled={submitting} onClick={createPayment} className="mt-2 w-full px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 shadow-sm">{submitting ? 'Creating…' : 'Create payment'}</button>
</div>
</div>
)}
{mode === 'refund' && (
<>
<RefundSection
payments={payments}
allUsers={allUsers}
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'); }}
/>
{loadingUsers && <div className="text-xs text-gray-500">Loading users</div>}
</>
)}
{mode === 'donation' && (
<>
<DonationAssignSection
payments={payments}
allUsers={allUsers}
registrations={registrations}
regOutstanding={regOutstanding}
onDone={async()=>{ await loadPayments(); await loadRegistrations(); setInfo('Donation assigned to registration'); }}
/>
{loadingUsers && <div className="text-xs text-gray-500">Loading users</div>}
</>
)}
{mode === 'link' && (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="text-lg font-semibold mb-3">Create payment link</div>
<div className="grid sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">User</label>
<UserSearchField allUsers={allUsers} value={linkUserId} onChange={uid => { setLinkUserId(uid); setLinkRegistrationId(""); setLinkResult(null); }} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Registration</label>
<select className="w-full max-w-full border rounded px-3 py-2 text-sm truncate" value={linkRegistrationId} onChange={e => setLinkRegistrationId(e.target.value)} disabled={!linkUserId}>
<option value="">Select registration</option>
<RegistrationOptions regs={regsForLinkUser} regOutstanding={regOutstanding} />
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Amount</label>
<input className="w-full border rounded px-3 py-2 text-sm" type="number" step="0.01" value={linkAmount} onChange={e => setLinkAmount(e.target.value)} disabled={!linkRegistrationId} />
<div className="text-[10px] text-gray-500 mt-1">Defaults to the outstanding balance; minimum R15 for a partial payment.</div>
</div>
</div>
<div className="mt-3">
<button disabled={linkGenerating || !linkRegistrationId} onClick={generatePaymentLink} className="w-full px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 shadow-sm">
{linkGenerating ? 'Generating…' : 'Generate link'}
</button>
</div>
{linkResult && (
<div className="mt-4 p-3 border rounded-lg bg-gray-50 space-y-2">
<div className="text-xs text-gray-600">Link for R {linkResult.amount.toFixed(2)}:</div>
<div className="text-sm break-all font-mono bg-white border rounded px-2 py-1.5">{linkResult.redirectUrl}</div>
<div className="flex flex-wrap gap-2 pt-1">
<button onClick={copyPaymentLink} className="px-3 py-1.5 text-xs rounded bg-gray-200 hover:bg-gray-300 text-gray-800">{linkCopied ? 'Copied!' : 'Copy link'}</button>
<button disabled={linkSending==='email'} onClick={() => sendPaymentLink('email')} className="px-3 py-1.5 text-xs rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50">{linkSending==='email' ? 'Sending…' : 'Send via Email'}</button>
<button disabled={linkSending==='whatsapp'} onClick={() => sendPaymentLink('whatsapp')} className="px-3 py-1.5 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50">{linkSending==='whatsapp' ? 'Sending…' : 'Send via WhatsApp'}</button>
</div>
<div className="text-[10px] text-amber-700">Generating a new link for this registration will replace this one the old link will no longer be honored.</div>
</div>
)}
</div>
)}
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex items-center justify-between mb-2">
<div className="text-lg font-semibold">Recent payments</div>
{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.filter(p => !isDonationLeg(p)).slice(0, 25).map(p => {
const amt = p.amount || 0;
const isRefund = amt < 0;
return (
<li key={p.id} className={`border rounded p-2 ${isRefund ? 'bg-red-50' : ''}`}>
<div className="flex justify-between">
<div className={`font-medium ${isRefund ? 'text-red-700' : ''}`}>{isRefund ? '-' : ''}R {Math.abs(amt).toFixed(2)} {p.isDonation ? <span className="text-xs text-emerald-700">(donation)</span> : null} {isRefund ? <span className="text-xs text-red-700">(refund)</span> : null}</div>
<div className="text-xs text-gray-500">{new Date(p.createdAt).toLocaleString()}</div>
</div>
<div className="text-xs text-gray-600">Method: {p.method || 'payment'}</div>
{(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>
);
})}
{payments.length === 0 && <li className="text-gray-500">No payments yet.</li>}
</ul>
</div>
</div>
<div className="space-y-6">
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="text-lg font-semibold mb-3">Today</div>
<div className="grid grid-cols-3 gap-2">
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Payments</div>
<div className="text-lg font-semibold">{todayTotals.count}</div>
</div>
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Revenue</div>
<div className="text-lg font-semibold">R {todayTotals.revenue.toFixed(2)}</div>
</div>
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Donations</div>
<div className="text-lg font-semibold">{todayTotals.donations}</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
type RefundSectionProps = {
payments: any[];
allUsers: any[];
usersList: { id: string; name: string }[];
regsForUser: (userId: string) => any[];
regOutstanding: Record<string, { totalDue: number; totalPaid: number; outstanding: number }>;
onDone: () => void | Promise<void>;
};
function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstanding, onDone }: RefundSectionProps) {
const { token } = useAuth();
const [userId, setUserId] = useState<string>("");
const [target, setTarget] = useState<'payment'|'registration'>('payment');
const [paymentId, setPaymentId] = useState<string>("");
const [registrationId, setRegistrationId] = useState<string>("");
const [amount, setAmount] = useState<string>("");
const [method, setMethod] = useState<string>("");
const [reason, setReason] = useState<string>('');
const [submitting, setSubmitting] = useState(false);
const [err, setErr] = useState<string | null>(null);
const paymentsForUser = useMemo(() => {
if (!userId) return [] as any[];
return payments.filter(p => String(p.userId) === String(userId) && (p.amount || 0) > 0);
}, [payments, userId]);
useEffect(() => {
// Reset dependent fields on changes
setPaymentId("");
setRegistrationId("");
setAmount("");
}, [userId, target]);
useEffect(() => {
// If a payment selected, default amount and refund method to that payment's own —
// still editable, but staff shouldn't have to remember to change it manually.
if (target === 'payment') {
const p = payments.find(pp => String(pp.id) === String(paymentId));
if (p) {
setAmount(String(Math.abs(p.amount || 0)));
setMethod(refundMethodForOriginal(p.method));
}
}
}, [paymentId, target, payments]);
const regs = useMemo(() => regsForUser(userId), [regsForUser, userId]);
const submitRefund = async () => {
if (!token) return;
setErr(null);
const amt = parseFloat(amount || '0');
if (!(amt > 0)) { setErr('Enter a valid refund amount'); return; }
if (!userId) { setErr('Select a user'); return; }
if (target === 'payment' && !paymentId) { setErr('Select a payment to refund'); return; }
if (target === 'registration' && !registrationId) { setErr('Select a registration to refund against'); return; }
if (!method) { setErr('Select a refund method'); return; }
try {
setSubmitting(true);
await apiFetch('/api/payments/refund', {
method: 'POST',
authToken: token!,
body: {
userId,
amount: amt,
method,
paymentId: target === 'payment' ? paymentId : undefined,
registrationId: target === 'registration' ? registrationId : undefined,
reason: reason || undefined
}
});
setUserId(""); setPaymentId(""); setRegistrationId(""); setAmount(""); setReason(""); setMethod("");
await onDone();
} catch (e: any) {
setErr(e?.message || 'Failed to create refund');
} finally {
setSubmitting(false);
}
};
return (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="text-lg font-semibold mb-3">Refund</div>
{err && <div className="p-2 mb-2 text-xs bg-red-50 text-red-700 border rounded">{err}</div>}
<div className="grid gap-2">
<label className="text-xs text-gray-600">User</label>
<UserSearchField allUsers={allUsers} value={userId} onChange={uid => setUserId(uid)} />
<label className="text-xs text-gray-600 mt-2">Refund target</label>
<div className="flex gap-4 text-sm">
<label className="flex items-center gap-2"><input type="radio" name="refundTarget" checked={target==='payment'} onChange={()=>setTarget('payment')} /> Payment</label>
<label className="flex items-center gap-2"><input type="radio" name="refundTarget" checked={target==='registration'} onChange={()=>setTarget('registration')} /> Registration</label>
</div>
{target === 'payment' ? (
<>
<label className="text-xs text-gray-600 mt-2">Payment</label>
<select className="w-full max-w-full border rounded px-3 py-2 text-sm truncate" value={paymentId} onChange={e => setPaymentId(e.target.value)} disabled={!userId}>
<option value="">Select payment</option>
{paymentsForUser.map((p:any)=>{
const label = `R ${(p.amount||0).toFixed(2)}${p.registrationId ? `Registration ${String(p.registrationId).slice(0,8)}` : (p.event?.title || p.eventId || 'Event')} — #${String(p.id).slice(0,8)}`;
return <option key={p.id} value={p.id} title={label}>{label}</option>;
})}
</select>
</>
) : (
<>
<label className="text-xs text-gray-600 mt-2">Registration</label>
<select className="w-full max-w-full border rounded px-3 py-2 text-sm truncate" value={registrationId} onChange={e => setRegistrationId(e.target.value)} disabled={!userId}>
<option value="">Select registration</option>
{regs.map((r:any)=>{
const out = regOutstanding[r.id]?.outstanding ?? 0;
const label = `${r.event?.title || r.eventId || 'Event'} — Outstanding: R ${out.toFixed(2)} — #${String(r.id).slice(0,8)}`;
return <option key={r.id} value={r.id} title={label}>{label}</option>;
})}
</select>
</>
)}
<div className="grid sm:grid-cols-2 gap-2 mt-2">
<div>
<label className="block text-xs text-gray-600 mb-1">Amount</label>
<input type="number" step="0.01" className="w-full border rounded px-3 py-2 text-sm" value={amount} onChange={e => setAmount(e.target.value)} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Method</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={method} onChange={e => setMethod(e.target.value)}>
<option value="">Select method</option>
<option value="cash-refund">Cash refund</option>
<option value="card-refund">Card refund</option>
<option value="eft-refund">EFT refund</option>
<option value="voucher-refund">Voucher refund</option>
</select>
<div className="text-[10px] text-gray-500 mt-1">Mirrors the method being refunded this is what nets against that method's total in reports.</div>
</div>
</div>
<label className="block text-xs text-gray-600 mt-2">Reason (optional)</label>
<input type="text" className="w-full border rounded px-3 py-2 text-sm" value={reason} onChange={e => setReason(e.target.value)} placeholder="Reason or note…" />
<button disabled={submitting} onClick={submitRefund} className="mt-2 px-3 py-1.5 text-sm rounded bg-rose-600 text-white hover:bg-rose-700 disabled:opacity-50 shadow-sm">{submitting ? 'Recording' : 'Record refund'}</button>
</div>
</div>
);
}
type DonationAssignSectionProps = {
payments: any[];
allUsers: any[];
registrations: any[];
regOutstanding: Record<string, { totalDue: number; totalPaid: number; outstanding: number }>;
onDone: () => void | Promise<void>;
};
// Select a user, then one of their registrations, then one of the unassigned donations for
// that registration's event. The backend rejects the assignment if either the donation's own
// event or the target registration's event is closed (cashed up) this UI filters both out
// proactively so staff aren't offered a choice that will just be rejected on submit.
function DonationAssignSection({ payments, allUsers, registrations, regOutstanding, onDone }: DonationAssignSectionProps) {
const { token } = useAuth();
const [userId, setUserId] = useState<string>("");
const [registrationId, setRegistrationId] = useState<string>("");
const [paymentId, setPaymentId] = useState<string>("");
const [amountStr, setAmountStr] = useState<string>("");
const [assigning, setAssigning] = useState(false);
const [err, setErr] = useState<string | null>(null);
useEffect(() => { setRegistrationId(""); setPaymentId(""); }, [userId]);
useEffect(() => { setPaymentId(""); }, [registrationId]);
// Registrations for the selected user that can actually receive a donation: an outstanding
// balance to apply it to, and an event that isn't closed.
const regsForUser = useMemo(() => {
if (!userId) return [] as any[];
return registrations.filter((r: any) => {
if (String(r.user?.id || r.userId) !== String(userId)) return false;
if (r.status === 'cancelled') return false;
if (r.event?.cashupStatus === 'closed') return false;
const out = regOutstanding[r.id]?.outstanding ?? 0;
return out > 0.000001;
});
}, [registrations, regOutstanding, userId]);
const selectedRegistration = useMemo(
() => registrations.find((r: any) => String(r.id) === String(registrationId)),
[registrations, registrationId]
);
// 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. A refund of the donation
// itself also creates such a leg, with a negative amount — Math.abs() so a refund reduces the
// remaining balance instead of inflating it (a raw signed sum would subtract a negative,
// adding the refund back on top of what's left to allocate).
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) + Math.abs(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) => {
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'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(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.
useEffect(() => {
setAmountStr(maxAllocatable > 0 ? maxAllocatable.toFixed(2) : "");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [paymentId]);
const amountNum = parseFloat(amountStr || "0");
const leftover = selectedDonation ? Math.max(0, donationRemaining - amountNum) : 0;
const assign = async () => {
if (!token) return;
setErr(null);
if (!paymentId || !registrationId) { setErr('Select a donation and a registration'); return; }
if (!(amountNum > 0)) { setErr('Enter a valid amount to allocate'); return; }
if (amountNum > maxAllocatable + 0.000001) { setErr(`Cannot allocate more than R ${maxAllocatable.toFixed(2)}`); return; }
try {
setAssigning(true);
await apiFetch('/api/payments/assign-donation', {
method: 'PUT',
authToken: token,
body: { paymentId, registrationId, amount: amountNum }
});
setUserId(""); setRegistrationId(""); setPaymentId(""); setAmountStr("");
await onDone();
} catch (e: any) {
setErr(e?.message || 'Failed to assign donation');
} finally {
setAssigning(false);
}
};
return (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="text-lg font-semibold mb-3">Assign donation to a registration</div>
{err && <div className="p-2 mb-2 text-xs bg-red-50 text-red-700 border rounded">{err}</div>}
<div className="grid gap-2">
<label className="text-xs text-gray-600">1. User</label>
<UserSearchField allUsers={allUsers} value={userId} onChange={setUserId} />
<label className="text-xs text-gray-600 mt-2">2. Registration</label>
<select
className="w-full max-w-full border rounded px-3 py-2 text-sm truncate"
value={registrationId}
onChange={e => setRegistrationId(e.target.value)}
disabled={!userId}
>
<option value="">Select registration</option>
<RegistrationOptions regs={regsForUser} regOutstanding={regOutstanding} />
</select>
{userId && regsForUser.length === 0 && (
<div className="text-xs text-gray-500">No eligible registrations for this user either nothing outstanding, or the event is closed.</div>
)}
<label className="text-xs text-gray-600 mt-2">3. Donation</label>
<select
className="w-full max-w-full border rounded px-3 py-2 text-sm truncate"
value={paymentId}
onChange={e => setPaymentId(e.target.value)}
disabled={!registrationId}
>
<option value="">Select donation</option>
{donationsForEvent.map((p: any) => {
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 donations with a remaining balance for this event.</div>
)}
{selectedDonation && (
<>
<label className="text-xs text-gray-600 mt-2">4. Amount to allocate</label>
<input
type="number"
step="0.01"
min="0.01"
max={maxAllocatable}
className="w-full border rounded px-3 py-2 text-sm"
value={amountStr}
onChange={e => setAmountStr(e.target.value)}
/>
<div className="text-xs text-gray-500">
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>
</>
)}
<button
disabled={!paymentId || assigning || !(amountNum > 0)}
onClick={assign}
className="mt-2 px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 shadow-sm"
>
{assigning ? 'Assigning…' : 'Assign donation'}
</button>
</div>
</div>
);
}
export default function PaymentsPage() {
return (
<Suspense fallback={<div className="p-6">Loading...</div>}>
<PaymentsContent />
</Suspense>
);
}