"use client"; import React, { useEffect, useMemo, useRef, useState } from "react"; import { useAuth } from "@/hooks/useAuth"; import { useRouter } from "next/navigation"; import { apiFetch, fetchAllUsers } from "@/lib/api"; import { scoreUser } from "@/lib/fuzzyMatch"; import { useDismissingState } from "@/hooks/useDismissingState"; import { UserPlus, CreditCard } from "lucide-react"; // ─── Pricing helpers ───────────────────────────────────────────────────────── function effectiveOptionUnit(opt: any): number { const base = opt.price || 0; const tiers = (Array.isArray(opt.earlyBirdTiers) ? opt.earlyBirdTiers : []).filter((t: any) => !t.variantId); if (tiers.length === 0) return base; const now = new Date(); const applicable = tiers .map((t: any) => ({ ...t, deadline: new Date(t.deadline) })) .filter((t: any) => now < t.deadline) .sort((a: any, b: any) => a.deadline - b.deadline || (a.order || 0) - (b.order || 0) || a.price - b.price); return applicable.length > 0 ? applicable[0].price : base; } function effectiveVariantUnit(opt: any, variant: any): number { const base = variant.price !== null && variant.price !== undefined ? variant.price : opt.price || 0; const allTiers = Array.isArray(opt.earlyBirdTiers) ? opt.earlyBirdTiers : []; const variantTiers = allTiers.filter((t: any) => t.variantId === variant.id); const tiers = variantTiers.length > 0 ? variantTiers : allTiers.filter((t: any) => !t.variantId); if (tiers.length === 0) return base; const now = new Date(); const applicable = tiers .map((t: any) => ({ ...t, deadline: new Date(t.deadline) })) .filter((t: any) => now < t.deadline) .sort((a: any, b: any) => a.deadline - b.deadline || (a.order || 0) - (b.order || 0) || a.price - b.price); return applicable.length > 0 ? applicable[0].price : base; } 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() { const { user, loading, token } = useAuth(); const router = useRouter(); 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 [events, setEvents] = useState([]); const [selectedEventId, setSelectedEventId] = useState(""); const [options, setOptions] = useState([]); // All system users for fuzzy lookup const [allUsers, setAllUsers] = useState([]); const [guest, setGuest] = useState({ name: "", email: "", phoneNumber: "" }); const [registerAsGuest, setRegisterAsGuest] = useState(false); const [notifPref, setNotifPref] = useState<"email" | "whatsapp" | "both">("email"); const [quantities, setQuantities] = useState>({}); // User search state const [userQuery, setUserQuery] = useState(""); const [dropdownOpen, setDropdownOpen] = useState(false); const searchRef = useRef(null); const [submitting, setSubmitting] = useState(false); const [message, setMessage] = useDismissingState(null); const [error, setError] = useDismissingState(null); // ── Tabs ────────────────────────────────────────────────────────────────── const [tab, setTab] = useState<"register" | "payment">("register"); // ── Record Payment tab state ───────────────────────────────────────────── const [allRegistrations, setAllRegistrations] = useState([]); const [payUserId, setPayUserId] = useState(""); const [payUserQuery, setPayUserQuery] = useState(""); const [payDropdownOpen, setPayDropdownOpen] = useState(false); const paySearchRef = useRef(null); const [payRegistrationId, setPayRegistrationId] = useState(""); const [payAmount, setPayAmount] = useState(""); const [payMethod, setPayMethod] = useState("cash"); const [payPaidAtLocal, setPayPaidAtLocal] = useState(""); const [paySubmitting, setPaySubmitting] = useState(false); const [payMessage, setPayMessage] = useDismissingState(null); const [payError, setPayError] = useDismissingState(null); // Load all users for client-side fuzzy matching useEffect(() => { if (!token) return; fetchAllUsers(token) .then(users => setAllUsers(users)) .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("/api/registrations", { authToken: token }) .then(regs => setAllRegistrations(Array.isArray(regs) ? regs : [])) .catch(() => {}); }, [token]); const regOutstanding = useMemo(() => { const map: Record = {}; 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("/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("/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 []; return allUsers .map(u => ({ u, score: scoreUser(u, userQuery) })) .filter(x => x.score >= 0.45) .sort((a, b) => b.score - a.score) .slice(0, 6) .map(x => x.u); }, [userQuery, allUsers]); const selectUser = (u: any) => { setGuest({ name: u.name || "", email: u.email || "", phoneNumber: u.phoneNumber || "" }); setUserQuery(u.name || ""); setDropdownOpen(false); }; // Close dropdown on outside click useEffect(() => { const handler = (e: MouseEvent) => { if (searchRef.current && !searchRef.current.contains(e.target as Node)) { setDropdownOpen(false); } }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, []); useEffect(() => { (async () => { try { if (!token) return; const evs = await apiFetch("/api/events/all", { authToken: token }); const now = Date.now(); const active = (evs || []).filter(ev => { const t = new Date(ev.endDate).getTime(); // Manual registration is rejected server-side for closed (cashed-up) events — // don't offer them here even in the rare case one is closed before it ends. return !isNaN(t) && t > now && ev.cashupStatus !== 'closed'; }); active.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()); setEvents(active); } catch (e: any) { // ignore } })(); }, [token]); useEffect(() => { const ev = events.find(e => e.id === selectedEventId); if (ev) { const opts = ev.options || ev.eventOptions || []; setOptions(opts); const map: Record = {}; opts.forEach((o: any) => { if ((o.variants || []).length > 0) { (o.variants as any[]).forEach(v => { map[`${o.id}::${v.id}`] = 0; }); } else { map[o.id] = 0; } }); setQuantities(map); } else { setOptions([]); setQuantities({}); } }, [selectedEventId, events]); const totalDue = useMemo(() => { return options.reduce((sum, o) => { if ((o.variants || []).length > 0) { return sum + (o.variants as any[]).reduce((vs: number, v: any) => vs + (quantities[`${o.id}::${v.id}`] || 0) * effectiveVariantUnit(o, v), 0); } return sum + (quantities[o.id] || 0) * effectiveOptionUnit(o); }, 0); }, [options, quantities]); const submit = async () => { if (!token) return; setError(null); setMessage(null); if (!selectedEventId) { setError("Please select an event."); return; } if (!guest.name || (!registerAsGuest && !guest.email)) { setError("Guest name and email are required."); return; } const opts = Object.entries(quantities) .filter(([, qty]) => qty > 0) .map(([key, quantity]) => { const [eventOptionId, variantId] = key.split("::"); return { eventOptionId, quantity, ...(variantId ? { variantId } : {}) }; }); if (opts.length === 0) { setError("Please select at least one ticket option."); return; } try { setSubmitting(true); const hasEmail = !!guest.email.trim(); const hasPhone = !!guest.phoneNumber.trim(); const resolvedPref = hasEmail && hasPhone ? notifPref : hasPhone ? "whatsapp" : "email"; const res = await apiFetch("/api/registrations/manual", { method: "POST", authToken: token, body: { eventId: selectedEventId, options: opts, user: guest, guestOnly: registerAsGuest, notificationPreference: resolvedPref, } }); 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); setNotifPref("email"); setUserQuery(""); setDropdownOpen(false); setQuantities(prev => Object.fromEntries(Object.keys(prev).map(k => [k, 0]))); } catch (e: any) { setError(e?.message || "Failed to create manual registration"); } finally { setSubmitting(false); } }; return (

Manual registration

{!canView && (
You need supervisor or admin access to use this page.
)}
{tab === 'register' && ( <> {message &&
{message}
} {error &&
{error}
}
1) Choose event
{selectedEventId && (
{(() => { const ev = events.find(e => e.id === selectedEventId); if (!ev) return null; return <>
{new Date(ev.startDate).toLocaleString()} - {new Date(ev.endDate).toLocaleString()}
; })()}
)}
2) Guest details
{/* ── User lookup ─────────────────────────────────────────── */}
{ setUserQuery(e.target.value); setDropdownOpen(true); }} onFocus={() => { if (userQuery.length >= 2) setDropdownOpen(true); }} /> {dropdownOpen && userQuery.trim().length >= 2 && (
{matchedUsers.length > 0 ? ( <>
{matchedUsers.length} match{matchedUsers.length !== 1 ? "es" : ""} — click to auto-fill
{matchedUsers.map(u => ( ))} ) : (
No matching users found — fill in details below manually.
)}
)}
setRegisterAsGuest(e.target.checked)} />
setGuest({ ...guest, name: e.target.value })} /> setGuest({ ...guest, email: e.target.value })} required={!registerAsGuest} /> { const v = e.target.value; setGuest({ ...guest, phoneNumber: v }); if (v.trim() && !guest.email.trim()) setNotifPref("whatsapp"); else if (!v.trim() && guest.email.trim()) setNotifPref("email"); }} /> {/* Preference selector */} {(() => { const hasEmail = !!guest.email.trim(); const hasPhone = !!guest.phoneNumber.trim(); if (!hasEmail && !hasPhone) return null; if (hasEmail && !hasPhone) return (

Tickets will be sent via email.

); if (hasPhone && !hasEmail) return (

Tickets will be sent via WhatsApp.

); return (
{(["email", "whatsapp", "both"] as const).map((p) => ( ))}
); })()} {guest.name && ( )}
3) Select ticket options
{options.length === 0 ? (
Select an event to view options.
) : (
{options.map(opt => { const hasVariants = (opt.variants || []).length > 0; if (hasVariants) { return (
{opt.name}{opt.isMainTicket ? • Main : null}
{(opt.variants as any[]).map((v: any) => { const unit = effectiveVariantUnit(opt, v); const basePrice = v.price !== null && v.price !== undefined ? v.price : opt.price; const key = `${opt.id}::${v.id}`; return (
{v.name}
{fmtPrice(unit)} {unit < basePrice && basePrice > 0 && (early bird, was {fmtPrice(basePrice)})}
setQuantities(q => ({ ...q, [key]: Math.max(0, parseInt(e.target.value || '0')) }))} />
); })}
); } const unit = effectiveOptionUnit(opt); return (
{opt.name}
{fmtPrice(unit)} {unit < opt.price && opt.price > 0 && (early bird, was {fmtPrice(opt.price)})} {opt.isMainTicket ? " • Main" : ""}
setQuantities(q => ({ ...q, [opt.id]: Math.max(0, parseInt(e.target.value || '0')) }))} />
); })}
)}
Total due: R {totalDue.toFixed(2)}
)} {tab === 'payment' && (
{payMessage &&
{payMessage}
} {payError &&
{payError}
}
Record a payment
setPayAmount(e.target.value)} />
{ setPayUserQuery(e.target.value); setPayDropdownOpen(true); if (!e.target.value) { setPayUserId(""); setPayRegistrationId(""); } }} onFocus={() => { if (payUserQuery.length >= 2) setPayDropdownOpen(true); }} /> {payDropdownOpen && payUserQuery.trim().length >= 2 && (
{payMatchedUsers.length > 0 ? ( <>
{payMatchedUsers.length} match{payMatchedUsers.length !== 1 ? "es" : ""}
{payMatchedUsers.map(u => ( ))} ) : (
No matching users found.
)}
)}
{!payUserId &&
Search for a user above to see their registrations.
}
setPayPaidAtLocal(e.target.value)} max={new Date().toISOString().slice(0,16)} />
Leave blank to use current time
)}
); }