Initial commit
Next.js + Express event management app for Hope Family Church.
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user