"use client"; import React, { useEffect, useMemo, useState } from "react"; import { useAuth } from "@/hooks/useAuth"; import { useRouter } from "next/navigation"; import { apiFetch } from "@/lib/api"; import { useDismissingState } from "@/hooks/useDismissingState"; import { ClipboardList } from "lucide-react"; const STATUS_OPTIONS = ["pending", "confirmed", "partial_paid", "paid", "cancelled"] as const; function fuzzyMatch(query: string, target: string): boolean { const q = query.toLowerCase(); const t = target.toLowerCase(); if (t.includes(q)) return true; const tokens = q.split(/\s+/).filter(Boolean); return tokens.every(tok => t.includes(tok)); } export default function AdminRegistrationsPage() { const { user, loading, token } = useAuth(); const router = useRouter(); const isAdmin = useMemo(() => user?.role === "admin", [user]); useEffect(() => { if (loading) return; if (!user) router.replace("/login"); }, [user, loading, router]); const [registrations, setRegistrations] = useState([]); const [events, setEvents] = useState([]); const [loadingRegs, setLoadingRegs] = useState(false); const [error, setError] = useDismissingState(null); const [info, setInfo] = useDismissingState(null); // Filters const [query, setQuery] = useState(""); const [eventFilter, setEventFilter] = useState(""); const [statusFilter, setStatusFilter] = useState(""); const [includePastEvents, setIncludePastEvents] = useState(false); const [includeInactiveEvents, setIncludeInactiveEvents] = useState(false); // Expanded rows + form responses cache const [expanded, setExpanded] = useState>(new Set()); const [formResponses, setFormResponses] = useState>({}); const [loadingForms, setLoadingForms] = useState>(new Set()); const loadRegistrations = async () => { if (!token) return; try { setLoadingRegs(true); const regs = await apiFetch("/api/registrations", { authToken: token }); const list = Array.isArray(regs) ? regs.sort((a, b) => { const eventCompare = (a.event?.startDate || a.eventId).localeCompare(b.event?.startDate || b.eventId); if (eventCompare !== 0) return eventCompare; return (a.user?.name || a.userId).localeCompare(b.user?.name || b.userId); }) : []; setRegistrations(list); } catch (e: any) { setError(e?.message || "Failed to load registrations"); } finally { setLoadingRegs(false); } }; const loadEvents = async () => { if (!token) return; try { const params = new URLSearchParams(); if (includePastEvents) params.set("includePast", "true"); if (includeInactiveEvents) params.set("includeInactive", "true"); const qs = params.toString() ? `?${params.toString()}` : ""; const evs = await apiFetch(`/api/events/all${qs}`, { authToken: token }); setEvents(Array.isArray(evs) ? evs.sort((a: any, b: any) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime()) : []); } catch {} }; useEffect(() => { loadRegistrations(); }, [token]); useEffect(() => { loadEvents(); }, [token, includePastEvents, includeInactiveEvents]); const toggleExpand = async (reg: any) => { const id = reg.id; const next = new Set(expanded); if (next.has(id)) { next.delete(id); setExpanded(next); return; } next.add(id); setExpanded(next); // Load form responses if not cached if (!formResponses[id] && !loadingForms.has(id)) { setLoadingForms(prev => new Set(prev).add(id)); try { const res = await apiFetch(`/api/forms/responses?registrationId=${encodeURIComponent(id)}`, { authToken: token! }); const items = Array.isArray(res?.items) ? res.items : (Array.isArray(res) ? res : []); setFormResponses(prev => ({ ...prev, [id]: items })); } catch { setFormResponses(prev => ({ ...prev, [id]: [] })); } finally { setLoadingForms(prev => { const s = new Set(prev); s.delete(id); return s; }); } } }; const cancelRegistration = async (reg: any) => { if (!token) return; setError(null); setInfo(null); if (!confirm(`Cancel registration #${String(reg.id).slice(0, 8)} for ${reg.user?.name || reg.userId}?`)) return; try { await apiFetch(`/api/registrations/${encodeURIComponent(reg.id)}`, { method: "DELETE", authToken: token }); setInfo("Registration cancelled"); await loadRegistrations(); } catch (e: any) { setError(e?.message || "Failed to cancel registration"); } }; const updateStatus = async (reg: any, status: string) => { if (!token) return; setError(null); setInfo(null); try { await apiFetch(`/api/registrations/${encodeURIComponent(reg.id)}`, { method: "PUT", authToken: token, body: { status } }); setInfo("Status updated"); await loadRegistrations(); } catch (e: any) { setError(e?.message || "Failed to update status"); } }; const eventIds = useMemo(() => new Set(events.map((ev: any) => ev.id)), [events]); const filtered = useMemo(() => { return registrations.filter((r: any) => { if (!eventIds.has(r.eventId)) return false; if (eventFilter && r.eventId !== eventFilter) return false; if (statusFilter && r.status !== statusFilter) return false; if (!query.trim()) return true; const haystack = [ r.id, r.user?.name, r.user?.email, r.user?.phoneNumber, r.userId, r.event?.title, r.eventId, r.status, ].map((x: any) => String(x || "")).join(" "); return fuzzyMatch(query.trim(), haystack); }); }, [registrations, query, eventFilter, statusFilter]); const statusColor = (s: string) => { if (s === "paid") return "text-green-700 bg-green-50"; if (s === "confirmed") return "text-blue-700 bg-blue-50"; if (s === "partial_paid") return "text-amber-700 bg-amber-50"; if (s === "cancelled") return "text-red-700 bg-red-50"; return "text-gray-700 bg-gray-50"; }; const totalDueFor = (r: any) => (r.registrationOptions || []).reduce((sum: number, opt: any) => { const unit = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) ? Number(opt.priceSnapshot) : (opt.eventOption?.price || 0); return sum + unit * (opt.quantity || 0); }, 0); const totalPaidFor = (r: any) => (r.payments || []).reduce((sum: number, p: any) => sum + (p.amount || 0), 0); // Aggregate stats across the currently filtered registrations — counts by status, plus // revenue/outstanding totals (cancelled registrations are excluded from the money totals // since they're not expected to be paid). const stats = useMemo(() => { const counts: Record = {}; let totalRevenue = 0; let totalOutstanding = 0; filtered.forEach((r: any) => { counts[r.status] = (counts[r.status] || 0) + 1; if (r.status === "cancelled") return; const due = totalDueFor(r); const paid = totalPaidFor(r); totalRevenue += paid; totalOutstanding += Math.max(due - paid, 0); }); return { counts, totalRevenue, totalOutstanding }; }, [filtered]); return (

Manage Registrations

{registrations.length} registration{registrations.length !== 1 ? "s" : ""} total

{!isAdmin && (
You need admin access to use this page.
)} {error &&
{error}
} {info &&
{info}
} {/* Aggregate stats */}
{STATUS_OPTIONS.map(s => (
{s.replace("_", " ")}
{stats.counts[s] || 0}
))}
Total revenue
R {stats.totalRevenue.toFixed(2)}
Total outstanding
R {stats.totalOutstanding.toFixed(2)}
{/* Filters */}
setQuery(e.target.value)} />
{filtered.length} of {registrations.length} registrations
    {filtered.map((r: any) => { const totalDue = totalDueFor(r); const totalPaid = totalPaidFor(r); const outstanding = Math.max(totalDue - totalPaid, 0); const isExpanded = expanded.has(r.id); const responses = formResponses[r.id]; const loadingResponse = loadingForms.has(r.id); return (
  • toggleExpand(r)}>
    {r.user?.name || r.userId} {r.event?.title || r.eventId} {r.status}
    {r.user?.email && {r.user.email}} {r.user?.phoneNumber && {r.user.phoneNumber}} R {totalPaid.toFixed(2)} paid {outstanding > 0.000001 && R {outstanding.toFixed(2)} owing} (R {totalDue.toFixed(2)} total) #{String(r.id).slice(0, 8)}
    {new Date(r.createdAt).toLocaleDateString()} {isExpanded ? "▲" : "▼"}
    {isExpanded && (
    e.stopPropagation()}> {/* Actions */}
    {/* Ticket options */} {(r.registrationOptions || []).length > 0 && (
    Ticket options
    {r.registrationOptions.map((opt: any) => (
    {opt.eventOption?.name || opt.eventOptionId} {opt.variant?.name && ({opt.variant.name})}
    {(() => { const unit = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) ? Number(opt.priceSnapshot) : (opt.variant?.price ?? opt.eventOption?.price ?? 0); return `Qty: ${opt.quantity} × R ${unit.toFixed(2)} = R ${(unit * (opt.quantity || 0)).toFixed(2)}`; })()}
    {opt.appliedTierId && (
    Early-bird price applied
    )}
    ))}
    Total: R {totalDue.toFixed(2)}
    )} {/* Payments */}
    Payments
    {(r.payments || []).length === 0 ? (
    No payments recorded.
    ) : (
    {r.payments.map((p: any) => (
    {p.amount < 0 ? '-' : ''}R {Math.abs(p.amount).toFixed(2)} · {p.method || 'payment'}
    {new Date(p.createdAt).toLocaleString()}
    {p.recordedBy?.name && String(p.recordedBy.id) !== String(p.userId) && (
    Recorded by: {p.recordedBy.name}
    )}
    ))}
    )}
    Paid: R {totalPaid.toFixed(2)}{outstanding > 0.000001 && · Owing: R {outstanding.toFixed(2)}}
    {/* Form responses */}
    Form responses
    {loadingResponse ? (
    Loading…
    ) : !responses || responses.length === 0 ? (
    No form responses submitted.
    ) : (
    {responses.map((resp: any, idx: number) => (
    Response #{idx + 1}
    {(resp.answers || []).map((a: any) => (
    {a.field?.label || a.fieldId}
    {a.value}
    ))}
    ))}
    )}
    {/* Metadata */}
    Registration ID: {r.id} · Created: {new Date(r.createdAt).toLocaleString()}
    )}
  • ); })} {filtered.length === 0 && (
  • {loadingRegs ? "Loading…" : "No registrations found."}
  • )}
); }