Files
hope-events/frontend/src/app/dashboard/admin/registrations/page.tsx
T
joshuaandClaude Sonnet 5 f0f8d4c242 Fix early-bird price blending and mislabeling; add contact-only events
- Early-bird pricing: RegistrationOption now tracks each purchase as a
  separate price tranche instead of overwriting a single price/quantity
  on repeat purchases, so buying more tickets after a tier expires no
  longer re-prices tickets already bought at the old price. Stock-limit
  checks, total-due calculation, and the Finance report's revenue-by-
  option are all tranche-aware; pages that showed one blended price per
  line now render/total each tranche. Viewing a pending/partially-paid
  registration (dashboard, detail page, or an event's registration
  list) now refreshes stale pricing on the spot instead of only at
  payment time.
- Fixed the "(early bird)" dashboard label incorrectly firing on any
  line priced below the base option price (e.g. a plain cheaper
  variant) — it now checks the real applied-tier flag.
- Added contact-only events (e.g. baptism): no registration/payment
  flow, shown on the public site with a "Contact us" popup instead of
  a Register button. Configurable via the admin event wizard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 17:26:35 +02:00

445 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<any[]>([]);
const [events, setEvents] = useState<any[]>([]);
const [loadingRegs, setLoadingRegs] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(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<Set<string>>(new Set());
const [formResponses, setFormResponses] = useState<Record<string, any[]>>({});
const [loadingForms, setLoadingForms] = useState<Set<string>>(new Set());
const loadRegistrations = async () => {
if (!token) return;
try {
setLoadingRegs(true);
const regs = await apiFetch<any[]>("/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<any[]>(`/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<any>(`/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";
};
// Backend attaches a tranche-aware totalDueComputed (exact even when a line spans multiple
// early-bird prices) — fall back to the old client-side estimate only for stale payloads.
const totalDueFor = (r: any) => r.totalDueComputed ?? (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<string, number> = {};
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 (
<div className="max-w-6xl mx-auto w-full p-6">
<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">
<ClipboardList className="w-5 h-5 text-brand-600" />
</div>
<div>
<h1 className="text-2xl font-semibold text-gray-900">Manage Registrations</h1>
<p className="text-sm text-gray-500">{registrations.length} registration{registrations.length !== 1 ? "s" : ""} total</p>
</div>
</div>
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200" onClick={() => router.push('/dashboard')}>Back</button>
</div>
{!isAdmin && (
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
You need 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>}
{/* Aggregate stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-2 mb-4">
{STATUS_OPTIONS.map(s => (
<div key={s} className="border rounded-lg p-2.5 bg-white shadow-sm">
<div className="text-xs text-gray-500 capitalize">{s.replace("_", " ")}</div>
<div className="text-lg font-semibold">{stats.counts[s] || 0}</div>
</div>
))}
<div className="border rounded-lg p-2.5 bg-white shadow-sm">
<div className="text-xs text-gray-500">Total revenue</div>
<div className="text-lg font-semibold text-green-700">R {stats.totalRevenue.toFixed(2)}</div>
</div>
<div className="border rounded-lg p-2.5 bg-white shadow-sm">
<div className="text-xs text-gray-500">Total outstanding</div>
<div className="text-lg font-semibold text-amber-700">R {stats.totalOutstanding.toFixed(2)}</div>
</div>
</div>
{/* Filters */}
<div className="border rounded-xl p-4 bg-white shadow-sm mb-4">
<div className="flex flex-wrap gap-3 items-end">
<div className="flex-1 min-w-48">
<label className="block text-xs text-gray-600 mb-1">Search (name, email, phone, event, ID)</label>
<input
className="w-full border rounded px-3 py-1.5 text-sm"
placeholder="Type to search…"
value={query}
onChange={e => setQuery(e.target.value)}
/>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select className="border rounded px-2 py-1.5 text-sm max-w-48" value={eventFilter} onChange={e => setEventFilter(e.target.value)}>
<option value="">All events</option>
{events.map(ev => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-500">
<label className="flex items-center gap-1 cursor-pointer">
<input type="checkbox" checked={includePastEvents} onChange={e => setIncludePastEvents(e.target.checked)} />
Past
</label>
<label className="flex items-center gap-1 cursor-pointer">
<input type="checkbox" checked={includeInactiveEvents} onChange={e => setIncludeInactiveEvents(e.target.checked)} />
Inactive
</label>
</div>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Status</label>
<select className="border rounded px-2 py-1.5 text-sm" value={statusFilter} onChange={e => setStatusFilter(e.target.value)}>
<option value="">All statuses</option>
{STATUS_OPTIONS.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
<button className="text-sm px-2 py-1.5 rounded bg-gray-100 hover:bg-gray-200" onClick={loadRegistrations} disabled={loadingRegs}>
{loadingRegs ? "Loading…" : "Refresh"}
</button>
</div>
<div className="mt-2 text-xs text-gray-500">{filtered.length} of {registrations.length} registrations</div>
</div>
<div className="border rounded-xl bg-white shadow-sm">
<ul className="divide-y text-sm">
{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 (
<li key={r.id} className="hover:bg-gray-50">
<div className="p-3 cursor-pointer" onClick={() => toggleExpand(r)}>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium">{r.user?.name || r.userId}</span>
<span className="text-gray-400"></span>
<span className="text-gray-700">{r.event?.title || r.eventId}</span>
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${statusColor(r.status)}`}>{r.status}</span>
</div>
<div className="text-xs text-gray-500 mt-0.5">
{r.user?.email && <span className="mr-2">{r.user.email}</span>}
{r.user?.phoneNumber && <span className="mr-2">{r.user.phoneNumber}</span>}
<span>R {totalPaid.toFixed(2)} paid</span>
{outstanding > 0.000001 && <span className="ml-2 text-amber-700">R {outstanding.toFixed(2)} owing</span>}
<span className="ml-2 text-gray-400">(R {totalDue.toFixed(2)} total)</span>
<span className="ml-2 text-gray-400">#{String(r.id).slice(0, 8)}</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs text-gray-400">{new Date(r.createdAt).toLocaleDateString()}</span>
<span className="text-gray-400 text-xs">{isExpanded ? "▲" : "▼"}</span>
</div>
</div>
</div>
{isExpanded && (
<div className="px-3 pb-3 bg-gray-50 border-t" onClick={e => e.stopPropagation()}>
{/* Actions */}
<div className="flex items-center gap-2 py-2 border-b border-gray-200 mb-3">
<select
className="px-2 py-1 text-xs rounded-lg border border-gray-300 bg-white shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 disabled:opacity-60"
value={r.status}
onChange={e => updateStatus(r, e.target.value)}
disabled={r.status === 'cancelled'}
>
{STATUS_OPTIONS.map(s => <option key={s} value={s}>{s}</option>)}
</select>
<button
className="px-2 py-1 text-xs rounded bg-red-600 text-white hover:bg-red-700 disabled:opacity-50"
onClick={() => cancelRegistration(r)}
disabled={r.status === 'cancelled'}
>
Cancel registration
</button>
</div>
{/* Ticket options */}
{(r.registrationOptions || []).length > 0 && (
<div className="mb-3">
<div className="text-xs font-semibold text-gray-600 mb-1 uppercase tracking-wide">Ticket options</div>
<div className="grid sm:grid-cols-2 gap-2">
{r.registrationOptions.map((opt: any) => {
// A line can span multiple price tranches (e.g. tickets bought
// before and after an early-bird tier expired) — show one row per
// tranche so its own price/tier status is accurate, not blended.
const tranches = Array.isArray(opt.tranches) && opt.tranches.length > 0
? opt.tranches
: [{
id: opt.id,
quantity: opt.quantity,
priceSnapshot: (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
? Number(opt.priceSnapshot)
: (opt.variant?.price ?? opt.eventOption?.price ?? 0),
appliedTierId: opt.appliedTierId,
}];
return (
<div key={opt.id} className="bg-white border rounded p-2 text-xs">
<div className="font-medium">
{opt.eventOption?.name || opt.eventOptionId}
{opt.variant?.name && <span className="text-gray-500"> ({opt.variant.name})</span>}
</div>
{tranches.map((t: any, idx: number) => {
const unit = Number(t.priceSnapshot || 0);
return (
<div key={t.id || idx} className="text-gray-500">
{`Qty: ${t.quantity} × R ${unit.toFixed(2)} = R ${(unit * (t.quantity || 0)).toFixed(2)}`}
{t.appliedTierId && (
<span className="text-green-700 text-[10px] ml-1">(early bird)</span>
)}
</div>
);
})}
</div>
);
})}
</div>
<div className="text-xs text-gray-700 mt-1 font-medium">Total: R {totalDue.toFixed(2)}</div>
</div>
)}
{/* Payments */}
<div className="mb-3">
<div className="text-xs font-semibold text-gray-600 mb-1 uppercase tracking-wide">Payments</div>
{(r.payments || []).length === 0 ? (
<div className="text-xs text-gray-400">No payments recorded.</div>
) : (
<div className="grid sm:grid-cols-2 gap-2">
{r.payments.map((p: any) => (
<div key={p.id} className="bg-white border rounded p-2 text-xs">
<div className="font-medium">
{p.amount < 0 ? '-' : ''}R {Math.abs(p.amount).toFixed(2)} · {p.method || 'payment'}
</div>
<div className="text-gray-500">{new Date(p.createdAt).toLocaleString()}</div>
{p.recordedBy?.name && String(p.recordedBy.id) !== String(p.userId) && (
<div className="text-gray-500">Recorded by: {p.recordedBy.name}</div>
)}
</div>
))}
</div>
)}
<div className="text-xs text-gray-700 mt-1 font-medium">
Paid: R {totalPaid.toFixed(2)}{outstanding > 0.000001 && <span className="text-amber-700"> · Owing: R {outstanding.toFixed(2)}</span>}
</div>
</div>
{/* Form responses */}
<div>
<div className="text-xs font-semibold text-gray-600 mb-1 uppercase tracking-wide">Form responses</div>
{loadingResponse ? (
<div className="text-xs text-gray-400">Loading</div>
) : !responses || responses.length === 0 ? (
<div className="text-xs text-gray-400">No form responses submitted.</div>
) : (
<div className="space-y-2">
{responses.map((resp: any, idx: number) => (
<div key={resp.id || idx} className="bg-white border rounded p-2">
<div className="text-xs font-medium text-gray-600 mb-1">Response #{idx + 1}</div>
<div className="grid sm:grid-cols-2 gap-1.5">
{(resp.answers || []).map((a: any) => (
<div key={a.id} className="bg-gray-50 border rounded p-1.5 text-xs">
<div className="text-[10px] text-gray-500">{a.field?.label || a.fieldId}</div>
<div className="font-medium">{a.value}</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
{/* Metadata */}
<div className="mt-2 text-[10px] text-gray-400">
Registration ID: {r.id} · Created: {new Date(r.createdAt).toLocaleString()}
</div>
</div>
)}
</li>
);
})}
{filtered.length === 0 && (
<li className="p-4 text-gray-500 text-sm">{loadingRegs ? "Loading…" : "No registrations found."}</li>
)}
</ul>
</div>
</div>
);
}