Initial commit

Next.js + Express event management app for Hope Family Church.
This commit is contained in:
2026-07-23 15:26:47 +02:00
commit 3d381944d2
246 changed files with 57565 additions and 0 deletions
@@ -0,0 +1,350 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
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] = useState<string | null>(null);
const [info, setInfo] = useState<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>
);
}