Files
hope-events/frontend/src/app/dashboard/admin/registrations/page.tsx
T
joshuaandClaude Sonnet 5 0de3f4be7d Fix financial double-counting, rebuild cashup accountability, and redesign the Reports page
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>
2026-08-04 14:52:54 +02:00

419 lines
20 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";
};
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>
);
}