- 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
1233 lines
61 KiB
TypeScript
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>
|
|
);
|
|
}
|