Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37681aec50 | |||
| 9f7785e660 | |||
| 663d0c0309 |
+11
-4
@@ -7,20 +7,27 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Fixed
|
### Added
|
||||||
|
|
||||||
- User dashboard: registration-edit errors now show inside the edit popup instead of being hidden behind it.
|
- 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.
|
||||||
- User dashboard: closed events are now hidden by default alongside past events (revealed via "Show past events"), and the Edit, Make payment, and Cancel registration actions no longer appear for closed or past registrations (also enforced server-side).
|
|
||||||
|
## [1.0.1] - 2026-07-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- User dashboard: event titles now show a status badge (Closed / Past / Inactive, in that precedence) wherever they're listed.
|
- User dashboard: event titles now show a status badge (Closed / Past / Inactive, in that precedence) wherever they're listed.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- User dashboard: registration-edit errors now show inside the edit popup instead of being hidden behind it.
|
||||||
|
- User dashboard: closed events are now hidden by default alongside past events (revealed via "Show past events"), and the Edit, Make payment, and Cancel registration actions no longer appear for closed or past registrations (also enforced server-side).
|
||||||
|
|
||||||
## [1.0.0] - 2026-07-23
|
## [1.0.0] - 2026-07-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Initial release of the Hope Family Church event management app (Next.js frontend + Express/Prisma backend).
|
- Initial release of the Hope Family Church event management app (Next.js frontend + Express/Prisma backend).
|
||||||
|
|
||||||
[Unreleased]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.0.0...main
|
[Unreleased]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.0.1...main
|
||||||
|
[1.0.1]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.0.0...v1.0.1
|
||||||
[1.0.0]: https://git.crosscode.co.za/joshua/hope-events/releases/tag/v1.0.0
|
[1.0.0]: https://git.crosscode.co.za/joshua/hope-events/releases/tag/v1.0.0
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "event-management-backend",
|
"name": "event-management-backend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"description": "Event Management System Backend",
|
"description": "Event Management System Backend",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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
|
// @route GET /api/payments/mypayments
|
||||||
// @access Private
|
// @access Private
|
||||||
const getUserPayments = async (req, res) => {
|
const getUserPayments = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Users should see payments they made (donations) and payments tied to their registrations
|
const page = Math.max(1, parseInt(req.query.page) || 1);
|
||||||
const payments = await prisma.payment.findMany({
|
const limit = Math.min(25, Math.max(1, parseInt(req.query.limit) || 25));
|
||||||
where: {
|
const skip = (page - 1) * limit;
|
||||||
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
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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) {
|
} catch (error) {
|
||||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -543,8 +543,9 @@ app.get('/docs', async (req, res) => {
|
|||||||
responses:[
|
responses:[
|
||||||
{ status:201, desc:'Recorded', body:{ id:'pay-uuid-...', amount:450, method:'cash', status:'succeeded', createdAt:'2025-06-10T09:00:00.000Z' }},
|
{ 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',
|
{ method:'GET', path:'/api/payments/mypayments', auth:'user+', desc:'Get own payment history (paginated, excludes donations)',
|
||||||
responses:[{ status:200, desc:'Success', body:[{ id:'pay-uuid-...', amount:450, method:'card', status:'succeeded', createdAt:'2025-06-01T11:00:00.000Z' }]}]},
|
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',
|
{ 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' },
|
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 }}]},
|
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 }}]},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hope-events-frontend",
|
"name": "hope-events-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
|
|||||||
@@ -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"
|
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")}
|
onClick={() => router.push("/dashboard/user/reset-password")}
|
||||||
>Reset password</button>
|
>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
|
<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"
|
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")}
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hope-events",
|
"name": "hope-events",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:backend": "cd backend && npm run dev",
|
"dev:backend": "cd backend && npm run dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user