Multi-phase visual facelift (design tokens, dashboards, sidebar/navbar shell, per-page help guides, and a layout/content pass across every remaining page) plus backend fixes to the dashboard KPI stats: - Admin/Supervisor dashboard KPIs (revenue, donations, registrations, tickets sold) now use a rolling trailing-month window (today back one calendar month, e.g. 9 May - 8 June if today is 8 June) instead of calendar month-to-date, which under-counted for most of the month. The comparison window shifts the same way, so like is still compared with like. - Reports deep-links from those stat tiles now match the same window (range=trailing_month, replacing range=this_month). - Design tokens (brand-* Tailwind scale + shadcn CSS variables), a site-wide contextual help button, fixed dashboard sidebar/navbar, Admin/Supervisor/Staff/User dashboard rebuilds backed by a new GET /api/stats/overview endpoint, a dedicated Contact page, Site Settings restyle with WhatsApp config folded in, and an Account activity feed backed by a new SecurityEvent model. - Every remaining page (home, events, registration flow, auth, legal, payment results, and every Admin/Supervisor/Staff/User tool page) restyled onto the same design tokens, several with real layout upgrades (home hero, events list/detail, donate page, auth pages). - 20+ new dedicated help guides so the whole site has page-specific help content instead of falling back to a generic guide. - Assorted fixes surfaced along the way: donation-leg double-counting in payment stats, donations not counting toward revenue, refund netting in per-method report breakdowns, and donation over-allocation after a refund. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
428 lines
20 KiB
TypeScript
428 lines
20 KiB
TypeScript
"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";
|
||
};
|
||
|
||
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<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) => (
|
||
<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>
|
||
)}
|
||
|
||
{/* 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>
|
||
);
|
||
} |