Files
hope-events/frontend/src/app/dashboard/admin/registrations/page.tsx
T
joshuaandClaude Sonnet 5 f3e6525467 Add registration status badges and auto-dismissing dashboard messages
Two consistency fixes requested after the payment-method work:

1. Registration status (pending/confirmed/partial_paid/paid/cancelled)
   was printed as a raw string on the user dashboard. Added
   RegistrationStatusBadge mirroring the existing EventStatusBadge
   pattern, using the same status colors already established on
   dashboard/admin/registrations.

2. Inline success/error banners across dashboard pages persisted
   indefinitely. Added a shared useDismissingState hook (drop-in
   useState replacement that auto-clears a truthy value after 7s,
   resetting the timer on each update) and swapped it in across ~24
   dashboard files. Excluded: message-only modal dialogs (ticket-
   scanning's success/error confirmations) and two states that mix
   live form-validation feedback with async results inside actively-
   open forms (the registration-edit modal's editError, the event
   create/edit modal's error) - those keep persisting until the user
   acts, since auto-hiding a "fix this field" message mid-edit would
   be a regression. Also fixed at-the-door's existing bespoke
   auto-dismiss timers (10s/15s, one mislabeled as "5s") to the same
   consistent 7s, and removed admin/settings' manual x dismiss button
   in favor of the same auto-only behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 10:17:53 +02:00

351 lines
16 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";
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";
};
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Manage Registrations</h1>
<button className="px-3 py-1.5 text-sm rounded 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>}
{/* 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 = (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 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 {totalDue.toFixed(2)}</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-indigo-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) => (
<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>
<div className="text-gray-500">
{(() => {
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)}`;
})()}
</div>
{opt.appliedTierId && (
<div className="text-green-700 text-[10px] mt-0.5">Early-bird price applied</div>
)}
</div>
))}
</div>
<div className="text-xs text-gray-700 mt-1 font-medium">Total: R {totalDue.toFixed(2)}</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>
);
}