Files
hope-events/frontend/src/app/dashboard/user/payments/page.tsx
T
joshuaandClaude Sonnet 5 8e6cb542d9 Full site redesign, help system, and dashboard stats fixes
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>
2026-08-06 15:00:10 +02:00

210 lines
8.5 KiB
TypeScript

"use client";
import React, { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { formatDateTime } from "@/lib/date";
import { formatPaymentMethod } from "@/lib/paymentMethod";
import { Receipt } from "lucide-react";
interface PaymentItem {
id: string;
amount: number;
method: string | null;
createdAt: string;
registrationId: string | null;
eventId: string | null;
registration?: { event?: { title?: string } | null } | null;
event?: { title?: string } | null;
}
const formatRand = (n: number) => `R ${Math.abs(n).toFixed(2)}`;
export default function UserPaymentsPage() {
const { user, loading, token } = useAuth();
const router = useRouter();
useEffect(() => {
if (loading) return;
if (!user) router.replace("/login");
}, [user, loading, router]);
const [payments, setPayments] = useState<PaymentItem[]>([]);
const [fetching, setFetching] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [total, setTotal] = useState(0);
// Filters
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [method, setMethod] = useState("");
const [kind, setKind] = useState<"" | "payment" | "refund">("");
const buildQuery = useCallback((p: number) => {
const qs = new URLSearchParams({ page: String(p), limit: "25" });
if (startDate) qs.set("startDate", startDate);
if (endDate) qs.set("endDate", endDate);
if (method) qs.set("method", method);
if (kind) qs.set("kind", kind);
return `/api/payments/mypayments?${qs.toString()}`;
}, [startDate, endDate, method, kind]);
const loadPayments = useCallback(async (p = 1) => {
if (!token) return;
setError(null);
setFetching(true);
try {
const res = await apiFetch<any>(buildQuery(p), { authToken: token });
setPayments(Array.isArray(res?.data) ? res.data : []);
setTotal(res?.total ?? 0);
setTotalPages(res?.pages ?? 1);
setPage(p);
} catch (e: any) {
setError(e?.message || "Failed to load payments");
} finally {
setFetching(false);
}
}, [token, buildQuery]);
useEffect(() => { if (token) loadPayments(1); }, [token, startDate, endDate, method, kind]);
const goToPage = (p: number) => loadPayments(p);
return (
<div className="max-w-4xl mx-auto w-full px-4 py-6 sm:px-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">
<Receipt className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">My Payments</h1>
</div>
<button
className="px-2.5 py-1 text-xs rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => router.push("/dashboard/user")}
>Back to dashboard</button>
</div>
{error && <p className="text-red-600 text-sm mb-3">{error}</p>}
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex flex-wrap items-end gap-3 mb-4">
<div>
<label className="block text-xs text-gray-600 mb-1">From</label>
<input
type="date"
className="border rounded px-2 py-1.5 text-sm"
value={startDate}
onChange={e => setStartDate(e.target.value)}
/>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">To</label>
<input
type="date"
className="border rounded px-2 py-1.5 text-sm"
value={endDate}
onChange={e => setEndDate(e.target.value)}
/>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Method</label>
<select className="border rounded px-2 py-1.5 text-sm" value={method} onChange={e => setMethod(e.target.value)}>
<option value="">All methods</option>
<option value="cash">Cash</option>
<option value="card">Card</option>
<option value="eft">EFT</option>
<option value="voucher">Voucher</option>
<option value="other">Other</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Type</label>
<select className="border rounded px-2 py-1.5 text-sm" value={kind} onChange={e => setKind(e.target.value as any)}>
<option value="">Payments &amp; refunds</option>
<option value="payment">Payments only</option>
<option value="refund">Refunds only</option>
</select>
</div>
{(startDate || endDate || method || kind) && (
<button
className="text-sm px-2 py-1.5 rounded bg-gray-100 hover:bg-gray-200"
onClick={() => { setStartDate(""); setEndDate(""); setMethod(""); setKind(""); }}
>Clear filters</button>
)}
</div>
<div className="text-xs text-gray-500 mb-2">
{total} payment{total !== 1 ? "s" : ""} total
{total > 0 && ` — page ${page} of ${totalPages}`}
</div>
<ul className="text-sm space-y-2">
{payments.map(p => {
const amt = p.amount || 0;
const isRefund = amt < 0;
const eventTitle = p.registration?.event?.title || p.event?.title;
return (
<li key={p.id} className={`border rounded p-2 ${isRefund ? "bg-red-50" : ""}`}>
<div className="flex justify-between">
<div className={`font-medium ${isRefund ? "text-red-700" : ""}`}>
{isRefund ? "-" : ""}{formatRand(amt)}
{isRefund && <span className="text-xs text-red-700 ml-1">(refund)</span>}
</div>
<div className="text-xs text-gray-500">{formatDateTime(p.createdAt)}</div>
</div>
<div className="text-xs text-gray-600">Method: {formatPaymentMethod(p.method)}</div>
{eventTitle && <div className="text-xs text-gray-600">Event: {eventTitle}</div>}
</li>
);
})}
{payments.length === 0 && !fetching && (
<li className="text-gray-500">No payments found.</li>
)}
{fetching && <li className="text-gray-400">Loading</li>}
</ul>
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4 text-sm">
<span className="text-gray-500">Page {page} of {totalPages}</span>
<div className="flex gap-1">
<button
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40"
disabled={page <= 1 || fetching}
onClick={() => goToPage(page - 1)}
> Prev</button>
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter(p => p === 1 || p === totalPages || Math.abs(p - page) <= 1)
.reduce<(number | "…")[]>((acc, p, i, arr) => {
if (i > 0 && (p as number) - (arr[i - 1] as number) > 1) acc.push("…");
acc.push(p);
return acc;
}, [])
.map((p, i) =>
p === "…" ? (
<span key={`ellipsis-${i}`} className="px-2 py-1 text-gray-400"></span>
) : (
<button
key={p}
className={`px-2 py-1 rounded ${page === p ? "bg-brand-600 text-white" : "bg-gray-100 hover:bg-gray-200"}`}
disabled={fetching}
onClick={() => goToPage(p as number)}
>{p}</button>
)
)}
<button
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40"
disabled={page >= totalPages || fetching}
onClick={() => goToPage(page + 1)}
>Next </button>
</div>
</div>
)}
</div>
</div>
);
}