Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37681aec50 |
@@ -7,6 +7,10 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- User dashboard: new "Payment history" page listing the user's own payments (donations excluded), with server-side pagination (25 per page), date range, method, and payment/refund filters.
|
||||
|
||||
## [1.0.1] - 2026-07-23
|
||||
|
||||
### Added
|
||||
|
||||
@@ -349,30 +349,57 @@ const getPayments = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Get user payments
|
||||
// @desc Get user payments (paginated, excludes donations, supports date range/method/kind filters)
|
||||
// @route GET /api/payments/mypayments
|
||||
// @access Private
|
||||
const getUserPayments = async (req, res) => {
|
||||
try {
|
||||
// Users should see payments they made (donations) and payments tied to their registrations
|
||||
const payments = await prisma.payment.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ userId: req.user.id }, // payments made by the user (includes donations)
|
||||
{ registration: { userId: req.user.id } } // payments tied to the user's registrations
|
||||
]
|
||||
},
|
||||
include: {
|
||||
registration: {
|
||||
include: {
|
||||
event: true
|
||||
}
|
||||
},
|
||||
event: true
|
||||
}
|
||||
});
|
||||
const page = Math.max(1, parseInt(req.query.page) || 1);
|
||||
const limit = Math.min(25, Math.max(1, parseInt(req.query.limit) || 25));
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
res.json(payments);
|
||||
// Users should see payments they made and payments tied to their registrations,
|
||||
// excluding donations.
|
||||
const where = {
|
||||
isDonation: false,
|
||||
OR: [
|
||||
{ userId: req.user.id },
|
||||
{ registration: { userId: req.user.id } }
|
||||
]
|
||||
};
|
||||
|
||||
if (req.query.method) {
|
||||
where.method = { startsWith: String(req.query.method), mode: 'insensitive' };
|
||||
}
|
||||
|
||||
if (req.query.kind === 'refund') {
|
||||
where.amount = { lt: 0 };
|
||||
} else if (req.query.kind === 'payment') {
|
||||
where.amount = { gte: 0 };
|
||||
}
|
||||
|
||||
if (req.query.startDate || req.query.endDate) {
|
||||
where.createdAt = {
|
||||
...(req.query.startDate && { gte: new Date(req.query.startDate) }),
|
||||
...(req.query.endDate && { lte: new Date(req.query.endDate) })
|
||||
};
|
||||
}
|
||||
|
||||
const [payments, total] = await prisma.$transaction([
|
||||
prisma.payment.findMany({
|
||||
where,
|
||||
include: {
|
||||
registration: { include: { event: true } },
|
||||
event: true
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: limit
|
||||
}),
|
||||
prisma.payment.count({ where })
|
||||
]);
|
||||
|
||||
res.json({ data: payments, total, page, limit, pages: Math.ceil(total / limit) });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
|
||||
@@ -543,8 +543,9 @@ app.get('/docs', async (req, res) => {
|
||||
responses:[
|
||||
{ status:201, desc:'Recorded', body:{ id:'pay-uuid-...', amount:450, method:'cash', status:'succeeded', createdAt:'2025-06-10T09:00:00.000Z' }},
|
||||
]},
|
||||
{ method:'GET', path:'/api/payments/mypayments', auth:'user+', desc:'Get own payment history',
|
||||
responses:[{ status:200, desc:'Success', body:[{ id:'pay-uuid-...', amount:450, method:'card', status:'succeeded', createdAt:'2025-06-01T11:00:00.000Z' }]}]},
|
||||
{ method:'GET', path:'/api/payments/mypayments', auth:'user+', desc:'Get own payment history (paginated, excludes donations)',
|
||||
queryParams:{ page:'Page (default 1)', limit:'Per page (default 25, max 25)', startDate:'ISO date, filters createdAt >=', endDate:'ISO date, filters createdAt <=', method:'Filter by method prefix (cash|card|eft|voucher)', kind:'payment|refund — filters by amount sign' },
|
||||
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'pay-uuid-...', amount:450, method:'card', status:'succeeded', createdAt:'2025-06-01T11:00:00.000Z' }], total:1, page:1, limit:25, pages:1 }}]},
|
||||
{ method:'GET', path:'/api/payments', auth:'supervisor+', desc:'List all payments',
|
||||
queryParams:{ page:'Page (default 1)', limit:'Per page (default 20)', eventId:'Filter by event', userId:'Filter by user', method:'Filter by method (cash|card|eft|donation)', startDate:'ISO date', endDate:'ISO date' },
|
||||
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'pay-uuid-...', amount:450, method:'card', user:{ name:'Jane Doe' }, registration:{ event:{ title:'Camp 2025' }}}], total:1 }}]},
|
||||
|
||||
@@ -629,6 +629,10 @@ export default function UserDashboardPage() {
|
||||
className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
|
||||
onClick={() => router.push("/dashboard/user/reset-password")}
|
||||
>Reset password</button>
|
||||
<button
|
||||
className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
|
||||
onClick={() => router.push("/dashboard/user/payments")}
|
||||
>Payment history</button>
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
|
||||
onClick={() => router.push("/dashboard/user/donate")}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
"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 { formatDateTime } from "@/lib/date";
|
||||
|
||||
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] = useState<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<"" | "cash" | "card" | "eft" | "voucher">("");
|
||||
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">
|
||||
<h1 className="text-2xl font-semibold">My Payments</h1>
|
||||
<button
|
||||
className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-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 as any)}>
|
||||
<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>
|
||||
</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 & 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: {p.method || "payment"}</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-indigo-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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user