Simplify self-service payments to full-amount checkout, add manual-page payment tab
Self-service "pay now" flows (registration success + user dashboard) now go straight to a Yoco checkout for the full outstanding balance instead of prompting for a partial amount; that page has been removed. Partial-amount payment links remain supervisor/admin-only via the Payments dashboard. Also adds a "Record Payment" tab to the supervisor manual registration page, pre-filled with the most recently created registration, so staff can capture a payment right after registering someone without leaving the page.
This commit is contained in:
@@ -39,6 +39,18 @@ function fmtPrice(n: number) {
|
||||
return n === 0 ? "Free" : `R ${n.toFixed(2)}`;
|
||||
}
|
||||
|
||||
// Effective unit price for an existing registration option (as returned by /api/registrations).
|
||||
// Prefers priceSnapshot (authoritative price captured at registration time) and only falls
|
||||
// back to a live early-bird calculation for legacy rows without a snapshot.
|
||||
function optionUnitPrice(opt: any): number {
|
||||
if (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) {
|
||||
return Number(opt.priceSnapshot);
|
||||
}
|
||||
const eo = opt.eventOption;
|
||||
if (opt.variant) return effectiveVariantUnit(eo, opt.variant);
|
||||
return effectiveOptionUnit(eo);
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ManualRegistrationPage() {
|
||||
@@ -76,6 +88,23 @@ export default function ManualRegistrationPage() {
|
||||
const [message, setMessage] = useDismissingState<string | null>(null);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
|
||||
// ── Tabs ──────────────────────────────────────────────────────────────────
|
||||
const [tab, setTab] = useState<"register" | "payment">("register");
|
||||
|
||||
// ── Record Payment tab state ─────────────────────────────────────────────
|
||||
const [allRegistrations, setAllRegistrations] = useState<any[]>([]);
|
||||
const [payUserId, setPayUserId] = useState<string>("");
|
||||
const [payUserQuery, setPayUserQuery] = useState("");
|
||||
const [payDropdownOpen, setPayDropdownOpen] = useState(false);
|
||||
const paySearchRef = useRef<HTMLDivElement>(null);
|
||||
const [payRegistrationId, setPayRegistrationId] = useState<string>("");
|
||||
const [payAmount, setPayAmount] = useState<string>("");
|
||||
const [payMethod, setPayMethod] = useState<string>("cash");
|
||||
const [payPaidAtLocal, setPayPaidAtLocal] = useState<string>("");
|
||||
const [paySubmitting, setPaySubmitting] = useState(false);
|
||||
const [payMessage, setPayMessage] = useDismissingState<string | null>(null);
|
||||
const [payError, setPayError] = useDismissingState<string | null>(null);
|
||||
|
||||
// Load all users for client-side fuzzy matching
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
@@ -84,6 +113,97 @@ export default function ManualRegistrationPage() {
|
||||
.catch(() => {});
|
||||
}, [token]);
|
||||
|
||||
// Load all registrations for the Record Payment tab (embeds payments, so outstanding
|
||||
// balances can be computed without a per-registration fetch loop).
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<any[]>("/api/registrations", { authToken: token })
|
||||
.then(regs => setAllRegistrations(Array.isArray(regs) ? regs : []))
|
||||
.catch(() => {});
|
||||
}, [token]);
|
||||
|
||||
const regOutstanding = useMemo(() => {
|
||||
const map: Record<string, { totalDue: number; totalPaid: number; outstanding: number }> = {};
|
||||
for (const r of allRegistrations) {
|
||||
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt) * (opt.quantity || 0), 0);
|
||||
const totalPaid = (r.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0);
|
||||
map[r.id] = { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid) };
|
||||
}
|
||||
return map;
|
||||
}, [allRegistrations]);
|
||||
|
||||
const payMatchedUsers = useMemo(() => {
|
||||
if (payUserQuery.trim().length < 2) return [];
|
||||
return allUsers
|
||||
.map(u => ({ u, score: scoreUser(u, payUserQuery) }))
|
||||
.filter(x => x.score >= 0.45)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 6)
|
||||
.map(x => x.u);
|
||||
}, [payUserQuery, allUsers]);
|
||||
|
||||
const selectPayUser = (u: any) => {
|
||||
setPayUserId(u.id);
|
||||
setPayUserQuery(u.name || "");
|
||||
setPayDropdownOpen(false);
|
||||
setPayRegistrationId("");
|
||||
};
|
||||
|
||||
// Close payment-tab user dropdown on outside click
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (paySearchRef.current && !paySearchRef.current.contains(e.target as Node)) {
|
||||
setPayDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, []);
|
||||
|
||||
const regsForPayUser = useMemo(() => {
|
||||
if (!payUserId) return [];
|
||||
return allRegistrations
|
||||
.filter((r: any) => String(r.userId || r.user?.id) === String(payUserId))
|
||||
.filter((r: any) => (regOutstanding[r.id]?.outstanding ?? 0) > 0.000001)
|
||||
.sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
}, [allRegistrations, payUserId, regOutstanding]);
|
||||
|
||||
const createManualPayment = async () => {
|
||||
if (!token) return;
|
||||
setPayError(null);
|
||||
setPayMessage(null);
|
||||
const amt = parseFloat(payAmount || "0");
|
||||
if (!amt || amt <= 0) { setPayError("Enter a valid amount"); return; }
|
||||
if (!payRegistrationId) { setPayError("Please select a registration"); return; }
|
||||
try {
|
||||
setPaySubmitting(true);
|
||||
await apiFetch<any>("/api/payments", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: {
|
||||
amount: amt,
|
||||
method: payMethod,
|
||||
userId: payUserId || undefined,
|
||||
registrationId: payRegistrationId,
|
||||
isDonation: false,
|
||||
paidAt: payPaidAtLocal ? new Date(payPaidAtLocal).toISOString() : undefined,
|
||||
}
|
||||
});
|
||||
setPayMessage(`Payment recorded (R ${amt.toFixed(2)}).`);
|
||||
setPayAmount("");
|
||||
setPayPaidAtLocal("");
|
||||
// Refresh registrations so the displayed outstanding balance updates
|
||||
try {
|
||||
const regs = await apiFetch<any[]>("/api/registrations", { authToken: token });
|
||||
setAllRegistrations(Array.isArray(regs) ? regs : []);
|
||||
} catch {}
|
||||
} catch (e: any) {
|
||||
setPayError(e?.message || "Failed to record payment");
|
||||
} finally {
|
||||
setPaySubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fuzzy match results (top 6, score threshold 0.45)
|
||||
const matchedUsers = useMemo(() => {
|
||||
if (userQuery.trim().length < 2) return [];
|
||||
@@ -193,6 +313,14 @@ export default function ManualRegistrationPage() {
|
||||
});
|
||||
setMessage("Manual registration created successfully.");
|
||||
|
||||
// Pre-load the Record Payment tab with this registration so it's ready to go the
|
||||
// moment a supervisor clicks over — the tab itself is never switched to automatically.
|
||||
setAllRegistrations(prev => [res, ...prev.filter((r: any) => r.id !== res.id)]);
|
||||
setPayUserId(res.userId || res.user?.id || "");
|
||||
setPayUserQuery(res.user?.name || guest.name || "");
|
||||
setPayRegistrationId(res.id);
|
||||
setPayDropdownOpen(false);
|
||||
|
||||
// Reset guest/ticket fields so the next registration starts from a clean slate
|
||||
setGuest({ name: "", email: "", phoneNumber: "" });
|
||||
setRegisterAsGuest(false);
|
||||
@@ -220,6 +348,19 @@ export default function ManualRegistrationPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'register' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
||||
<input type="radio" name="tab" value="register" className="hidden" checked={tab==='register'} onChange={() => setTab('register')} />
|
||||
Register
|
||||
</label>
|
||||
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'payment' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
||||
<input type="radio" name="tab" value="payment" className="hidden" checked={tab==='payment'} onChange={() => setTab('payment')} />
|
||||
Record Payment
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{tab === 'register' && (
|
||||
<>
|
||||
{message && <div className="p-3 mb-3 border rounded bg-emerald-50 text-emerald-800 text-sm">{message}</div>}
|
||||
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
|
||||
|
||||
@@ -447,6 +588,92 @@ export default function ManualRegistrationPage() {
|
||||
<div className="text-sm">Total due: <span className="font-semibold">R {totalDue.toFixed(2)}</span></div>
|
||||
<button disabled={submitting} onClick={submit} className="px-4 py-2 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 shadow-sm">{submitting ? 'Creating…' : 'Create registration'}</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'payment' && (
|
||||
<div className="max-w-xl">
|
||||
{payMessage && <div className="p-3 mb-3 border rounded bg-emerald-50 text-emerald-800 text-sm">{payMessage}</div>}
|
||||
{payError && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{payError}</div>}
|
||||
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="text-lg font-semibold mb-3">Record a payment</div>
|
||||
<div className="grid 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={payAmount} onChange={e => setPayAmount(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={payMethod} onChange={e => setPayMethod(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 ref={paySearchRef} className="relative">
|
||||
<label className="block text-xs text-gray-600 mb-1">User</label>
|
||||
<input
|
||||
className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
||||
placeholder="Search by name, email or phone…"
|
||||
value={payUserQuery}
|
||||
autoComplete="off"
|
||||
onChange={e => { setPayUserQuery(e.target.value); setPayDropdownOpen(true); if (!e.target.value) { setPayUserId(""); setPayRegistrationId(""); } }}
|
||||
onFocus={() => { if (payUserQuery.length >= 2) setPayDropdownOpen(true); }}
|
||||
/>
|
||||
{payDropdownOpen && payUserQuery.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">
|
||||
{payMatchedUsers.length > 0 ? (
|
||||
<>
|
||||
<div className="px-3 py-1.5 text-[11px] text-gray-400 bg-gray-50 border-b">
|
||||
{payMatchedUsers.length} match{payMatchedUsers.length !== 1 ? "es" : ""}
|
||||
</div>
|
||||
{payMatchedUsers.map(u => (
|
||||
<button
|
||||
key={u.id}
|
||||
type="button"
|
||||
className="w-full text-left px-3 py-2.5 hover:bg-indigo-50 border-b border-gray-100 last:border-b-0 transition-colors"
|
||||
onClick={() => selectPayUser(u)}
|
||||
>
|
||||
<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>
|
||||
<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={payRegistrationId} onChange={e => setPayRegistrationId(e.target.value)} disabled={!payUserId}>
|
||||
<option value="">Select registration…</option>
|
||||
{regsForPayUser.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>
|
||||
{!payUserId && <div className="text-xs text-gray-500 mt-1">Search for a user above to see their registrations.</div>}
|
||||
</div>
|
||||
<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={payPaidAtLocal} onChange={e => setPayPaidAtLocal(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 className="mt-3">
|
||||
<button disabled={paySubmitting} onClick={createManualPayment} className="w-full px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 shadow-sm">{paySubmitting ? 'Recording…' : 'Record payment'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user