Financial correctness (donation-leg model):
- Donations are no longer mutated when assigned to a registration; assignment now
creates an immutable "leg" record referencing the original donation instead.
- Fixed several places where money was double-counted once a donation was partially
or fully assigned (Payments, Revenue summary, Cashup reconciliation, Finance
report, Profit report, Master Orders, Revenue Detailed).
- Payments now record who recorded them (recordedBy), separate from who they're for.
Cashup:
- Per-user cash denomination counting (optional, any time) replaces the single
event-wide manual entry; the event's cash actual is the live sum of these counts.
- New "Payment accountability by staff member" breakdown across all methods, and a
read-only "Report" tab that opens automatically once an event is closed.
Reports page redesign:
- New shell: sidebar of universal filters (events, date range, past/inactive/closed
toggles), searchable/categorized report grid, and a popup viewer with
Print/Email/Excel/WhatsApp actions plus an in-app Reporting Guide.
- Visual pass: colored stat tiles and bar charts on most reports, matching mockups.
- PDF exports (download/Print/Email/WhatsApp) now share a branded design mirroring
the web report — colored header, stat tiles, bar chart, highlighted totals.
- Excel export now produces a styled .xlsx (via exceljs) instead of a plain CSV.
- Master Orders' "Donations made" table is now included in every export channel.
Bug fixes discovered while testing exports:
- Report emails now go through the shared, DB-configurable mail utility instead of
a one-off transporter that ignored Site Settings SMTP config.
- WhatsApp report sends now surface the actual WAWP API error and auto-recover a
disconnected session, instead of a bare axios status-code message.
Also: Admin-editable notification preference, richer Admin Registrations dashboard,
{{payment.link}} placeholder for Email/WhatsApp Attendees, and background
email/WhatsApp attendee sending.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
419 lines
20 KiB
TypeScript
419 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";
|
||
|
||
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">
|
||
<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>}
|
||
|
||
{/* 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-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>
|
||
)}
|
||
|
||
{/* 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>
|
||
);
|
||
} |