Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37681aec50 | |||
| 9f7785e660 | |||
| 663d0c0309 | |||
| 91b352de18 | |||
| 349686e182 | |||
| eb46ed8264 |
+17
-1
@@ -7,11 +7,27 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
||||||
|
|
||||||
|
- 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) });
|
||||||
}
|
}
|
||||||
@@ -788,11 +815,15 @@ const createYocoCheckout = async (req, res) => {
|
|||||||
// Branch 1: Registration payment — delegate to internal helper
|
// Branch 1: Registration payment — delegate to internal helper
|
||||||
if (registrationId) {
|
if (registrationId) {
|
||||||
// Authorisation check (the internal helper skips this)
|
// Authorisation check (the internal helper skips this)
|
||||||
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, select: { userId: true, eventId: true } });
|
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, select: { userId: true, eventId: true, event: { select: { endDate: true } } } });
|
||||||
if (!reg) { res.status(404); throw new Error('Registration not found'); }
|
if (!reg) { res.status(404); throw new Error('Registration not found'); }
|
||||||
if (reg.userId !== userId && req.user.role !== 'admin' && req.user.role !== 'supervisor') {
|
if (reg.userId !== userId && req.user.role !== 'admin' && req.user.role !== 'supervisor') {
|
||||||
res.status(403); throw new Error('Not authorized to create checkout for this registration');
|
res.status(403); throw new Error('Not authorized to create checkout for this registration');
|
||||||
}
|
}
|
||||||
|
// If event is in the past, block self-service payment (mirrors the registration-edit block)
|
||||||
|
if (req.user.role !== 'admin' && req.user.role !== 'supervisor' && reg.event?.endDate && new Date(reg.event.endDate).getTime() < Date.now()) {
|
||||||
|
res.status(400); throw new Error('This event has already ended; payment can no longer be made for this registration');
|
||||||
|
}
|
||||||
await assertEventOpen(reg.eventId, res);
|
await assertEventOpen(reg.eventId, res);
|
||||||
|
|
||||||
// Refresh early-bird pricing — updates priceSnapshot if any tier has expired or sold out.
|
// Refresh early-bird pricing — updates priceSnapshot if any tier has expired or sold out.
|
||||||
|
|||||||
@@ -415,7 +415,7 @@ const getUserRegistrations = async (req, res) => {
|
|||||||
const whereClause = showPast ? { userId: req.user.id } : {
|
const whereClause = showPast ? { userId: req.user.id } : {
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
status: { not: 'cancelled' },
|
status: { not: 'cancelled' },
|
||||||
event: { endDate: { gte: now } }
|
event: { endDate: { gte: now }, cashupStatus: { not: 'closed' } }
|
||||||
};
|
};
|
||||||
|
|
||||||
const registrations = await prisma.registration.findMany({
|
const registrations = await prisma.registration.findMany({
|
||||||
@@ -583,7 +583,8 @@ const updateRegistrationStatus = async (req, res) => {
|
|||||||
const cancelRegistration = async (req, res) => {
|
const cancelRegistration = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const registration = await prisma.registration.findUnique({
|
const registration = await prisma.registration.findUnique({
|
||||||
where: { id: req.params.id }
|
where: { id: req.params.id },
|
||||||
|
include: { event: { select: { endDate: true } } }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!registration) {
|
if (!registration) {
|
||||||
@@ -597,6 +598,12 @@ const cancelRegistration = async (req, res) => {
|
|||||||
throw new Error('Not authorized to cancel this registration');
|
throw new Error('Not authorized to cancel this registration');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If event is in the past, block self-cancellation (mirrors the edit-block above)
|
||||||
|
if (req.user.role !== 'admin' && registration.event?.endDate && new Date(registration.event.endDate).getTime() < Date.now()) {
|
||||||
|
res.status(400);
|
||||||
|
throw new Error('This event has already ended; registration can no longer be cancelled');
|
||||||
|
}
|
||||||
|
|
||||||
await assertEventOpen(registration.eventId, res);
|
await assertEventOpen(registration.eventId, res);
|
||||||
|
|
||||||
// Check if registration can be cancelled
|
// Check if registration can be cancelled
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -9,6 +9,25 @@ import { QrImage } from "@/components/shared/QrImage";
|
|||||||
// Helper formatters
|
// Helper formatters
|
||||||
const formatRand = (n: number) => `R ${n.toFixed(2)}`;
|
const formatRand = (n: number) => `R ${n.toFixed(2)}`;
|
||||||
const isFuture = (d: string | Date) => new Date(d).getTime() > Date.now();
|
const isFuture = (d: string | Date) => new Date(d).getTime() > Date.now();
|
||||||
|
const isEventOver = (ev: any) =>
|
||||||
|
!!ev && ((ev.endDate && new Date(ev.endDate).getTime() < Date.now()) || ev.cashupStatus === 'closed');
|
||||||
|
|
||||||
|
// Status badge for an event, shown wherever an event title is displayed on this page.
|
||||||
|
// Precedence when more than one applies: Closed > Past > Inactive.
|
||||||
|
function EventStatusBadge({ event }: { event: any }) {
|
||||||
|
if (!event) return null;
|
||||||
|
let label: string | null = null;
|
||||||
|
let className = '';
|
||||||
|
if (event.cashupStatus === 'closed') {
|
||||||
|
label = 'Closed'; className = 'bg-rose-50 text-rose-700';
|
||||||
|
} else if (event.endDate && new Date(event.endDate).getTime() < Date.now()) {
|
||||||
|
label = 'Past'; className = 'bg-amber-50 text-amber-700';
|
||||||
|
} else if (event.isActive === false) {
|
||||||
|
label = 'Inactive'; className = 'bg-gray-100 text-gray-500';
|
||||||
|
}
|
||||||
|
if (!label) return null;
|
||||||
|
return <span className={`text-[10px] px-1.5 py-0.5 rounded ${className}`}>{label}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
// Effective unit price for an event option (or one of its variants), early-bird aware.
|
// Effective unit price for an event option (or one of its variants), early-bird aware.
|
||||||
// Used by the registration editor, which works off raw /api/events/:id data rather than
|
// Used by the registration editor, which works off raw /api/events/:id data rather than
|
||||||
@@ -443,11 +462,13 @@ export default function UserDashboardPage() {
|
|||||||
const [editQuantities, setEditQuantities] = useState<Record<string, number>>({});
|
const [editQuantities, setEditQuantities] = useState<Record<string, number>>({});
|
||||||
const [editBaseQty, setEditBaseQty] = useState<Record<string, number>>({});
|
const [editBaseQty, setEditBaseQty] = useState<Record<string, number>>({});
|
||||||
const [editMinQty, setEditMinQty] = useState<Record<string, number>>({});
|
const [editMinQty, setEditMinQty] = useState<Record<string, number>>({});
|
||||||
|
const [editError, setEditError] = useState<string | null>(null);
|
||||||
|
|
||||||
const canEditActive = useMemo(() => {
|
// Whether the active registration's event still permits self-service changes
|
||||||
|
// (edit items, pay, cancel) — false once the event is past or its cashup is closed.
|
||||||
|
const canModifyActive = useMemo(() => {
|
||||||
if (!activeReg) return false;
|
if (!activeReg) return false;
|
||||||
const end = activeReg.event?.endDate ? new Date(activeReg.event.endDate).getTime() : Infinity;
|
return !isEventOver(activeReg.event);
|
||||||
return end >= Date.now();
|
|
||||||
}, [activeReg]);
|
}, [activeReg]);
|
||||||
|
|
||||||
const editTotal = useMemo(() => {
|
const editTotal = useMemo(() => {
|
||||||
@@ -464,6 +485,7 @@ export default function UserDashboardPage() {
|
|||||||
|
|
||||||
const beginEdit = async () => {
|
const beginEdit = async () => {
|
||||||
if (!activeReg || !token) return;
|
if (!activeReg || !token) return;
|
||||||
|
setEditError(null);
|
||||||
setEditLoading(true);
|
setEditLoading(true);
|
||||||
try {
|
try {
|
||||||
// Load event options (with variants) for this event
|
// Load event options (with variants) for this event
|
||||||
@@ -507,7 +529,7 @@ export default function UserDashboardPage() {
|
|||||||
setEditMinQty(minQtyMap);
|
setEditMinQty(minQtyMap);
|
||||||
setEditMode(true);
|
setEditMode(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as any)?.message || 'Failed to load event options');
|
setEditError((e as any)?.message || 'Failed to load event options');
|
||||||
} finally {
|
} finally {
|
||||||
setEditLoading(false);
|
setEditLoading(false);
|
||||||
}
|
}
|
||||||
@@ -515,6 +537,7 @@ export default function UserDashboardPage() {
|
|||||||
|
|
||||||
const saveEdit = async () => {
|
const saveEdit = async () => {
|
||||||
if (!activeReg || !token) return;
|
if (!activeReg || !token) return;
|
||||||
|
setEditError(null);
|
||||||
|
|
||||||
for (const [key, minQty] of Object.entries(editMinQty)) {
|
for (const [key, minQty] of Object.entries(editMinQty)) {
|
||||||
if (minQty <= 0) continue;
|
if (minQty <= 0) continue;
|
||||||
@@ -523,7 +546,7 @@ export default function UserDashboardPage() {
|
|||||||
const eo = editEventOptions.find((o: any) => o.id === eventOptionId);
|
const eo = editEventOptions.find((o: any) => o.id === eventOptionId);
|
||||||
const variant = variantId ? (eo?.variants || []).find((v: any) => v.id === variantId) : null;
|
const variant = variantId ? (eo?.variants || []).find((v: any) => v.id === variantId) : null;
|
||||||
const label = variant ? `${eo?.name || 'item'} (${variant.name})` : (eo?.name || 'item');
|
const label = variant ? `${eo?.name || 'item'} (${variant.name})` : (eo?.name || 'item');
|
||||||
setError(`Cannot reduce "${label}" below the ${minQty} already issued`);
|
setEditError(`Cannot reduce "${label}" below the ${minQty} already issued`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -539,13 +562,13 @@ export default function UserDashboardPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (payloadOptions.length === 0) {
|
if (payloadOptions.length === 0) {
|
||||||
setError('Please select at least one item');
|
setEditError('Please select at least one item');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Enforce client-side: do not reduce below paid
|
// Enforce client-side: do not reduce below paid
|
||||||
const paid = activeBill?.totalPaid || 0;
|
const paid = activeBill?.totalPaid || 0;
|
||||||
if (editTotal < paid) {
|
if (editTotal < paid) {
|
||||||
setError('Cannot reduce items below the amount already paid');
|
setEditError('Cannot reduce items below the amount already paid');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -588,7 +611,7 @@ export default function UserDashboardPage() {
|
|||||||
setInfo('Registration updated successfully');
|
setInfo('Registration updated successfully');
|
||||||
setEditMode(false);
|
setEditMode(false);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e?.message || 'Failed to update registration');
|
setEditError(e?.message || 'Failed to update registration');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -606,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")}
|
||||||
@@ -635,11 +662,7 @@ export default function UserDashboardPage() {
|
|||||||
{registrations
|
{registrations
|
||||||
// Hide cancelled always; hide past unless showPast
|
// Hide cancelled always; hide past unless showPast
|
||||||
.filter((r: any) => r.status !== 'cancelled')
|
.filter((r: any) => r.status !== 'cancelled')
|
||||||
.filter((r: any) => {
|
.filter((r: any) => showPast || !isEventOver(r.event))
|
||||||
if (showPast) return true;
|
|
||||||
const end = r.event?.endDate ? new Date(r.event.endDate).getTime() : Infinity;
|
|
||||||
return end >= Date.now();
|
|
||||||
})
|
|
||||||
.map((r: any) => {
|
.map((r: any) => {
|
||||||
const bill = billing[r.id];
|
const bill = billing[r.id];
|
||||||
const isCancelled = r.status === 'cancelled';
|
const isCancelled = r.status === 'cancelled';
|
||||||
@@ -653,7 +676,10 @@ export default function UserDashboardPage() {
|
|||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">{r.event?.title || r.eventId}</div>
|
<div className="font-medium flex items-center gap-2">
|
||||||
|
<span>{r.event?.title || r.eventId}</span>
|
||||||
|
<EventStatusBadge event={r.event} />
|
||||||
|
</div>
|
||||||
<div className="text-xs text-gray-600">
|
<div className="text-xs text-gray-600">
|
||||||
Status: {r.status}{isCancelled && <span className="ml-2 inline-block text-[10px] px-1.5 py-0.5 rounded bg-gray-200 text-gray-700">cancelled</span>}
|
Status: {r.status}{isCancelled && <span className="ml-2 inline-block text-[10px] px-1.5 py-0.5 rounded bg-gray-200 text-gray-700">cancelled</span>}
|
||||||
</div>
|
</div>
|
||||||
@@ -726,7 +752,10 @@ export default function UserDashboardPage() {
|
|||||||
<ul className="grid md:grid-cols-2 gap-3">
|
<ul className="grid md:grid-cols-2 gap-3">
|
||||||
{upcomingEvents.map(ev => (
|
{upcomingEvents.map(ev => (
|
||||||
<li key={ev.id} className="border rounded p-4">
|
<li key={ev.id} className="border rounded p-4">
|
||||||
<div className="font-medium">{ev.title}</div>
|
<div className="font-medium flex items-center gap-2">
|
||||||
|
<span>{ev.title}</span>
|
||||||
|
<EventStatusBadge event={ev} />
|
||||||
|
</div>
|
||||||
<div className="text-sm text-gray-600">{new Date(ev.startDate).toLocaleString()} - {new Date(ev.endDate).toLocaleString()}</div>
|
<div className="text-sm text-gray-600">{new Date(ev.startDate).toLocaleString()} - {new Date(ev.endDate).toLocaleString()}</div>
|
||||||
<div className="flex flex-wrap gap-2 mt-2">
|
<div className="flex flex-wrap gap-2 mt-2">
|
||||||
<button
|
<button
|
||||||
@@ -786,12 +815,12 @@ export default function UserDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{tickets.filter(t => showPast || (t.event?.endDate ? new Date(t.event.endDate).getTime() >= Date.now() : true)).length === 0 ? (
|
{tickets.filter(t => showPast || !isEventOver(t.event)).length === 0 ? (
|
||||||
<p className="text-gray-600 text-sm">No tickets yet.</p>
|
<p className="text-gray-600 text-sm">No tickets yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
<ul className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
{tickets
|
{tickets
|
||||||
.filter((t: any) => showPast || (t.event?.endDate ? new Date(t.event.endDate).getTime() >= Date.now() : true))
|
.filter((t: any) => showPast || !isEventOver(t.event))
|
||||||
.map((t: any) => {
|
.map((t: any) => {
|
||||||
const eventDate = t.event?.startDate ? new Date(t.event.startDate) : null;
|
const eventDate = t.event?.startDate ? new Date(t.event.startDate) : null;
|
||||||
const optionName = t.registrationOption?.eventOption?.name || "Ticket";
|
const optionName = t.registrationOption?.eventOption?.name || "Ticket";
|
||||||
@@ -804,6 +833,7 @@ export default function UserDashboardPage() {
|
|||||||
<input type="checkbox" checked={selected[t.id]} onChange={e => toggleSelect(t.id, e.target.checked)} />
|
<input type="checkbox" checked={selected[t.id]} onChange={e => toggleSelect(t.id, e.target.checked)} />
|
||||||
<span className="font-medium">{t.event?.title || t.eventId}</span>
|
<span className="font-medium">{t.event?.title || t.eventId}</span>
|
||||||
</label>
|
</label>
|
||||||
|
<EventStatusBadge event={t.event} />
|
||||||
{eventDate && <span className="text-xs text-gray-500">{formatDate(eventDate)}</span>}
|
{eventDate && <span className="text-xs text-gray-500">{formatDate(eventDate)}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-700">{type}</div>
|
<div className="text-sm text-gray-700">{type}</div>
|
||||||
@@ -827,11 +857,14 @@ export default function UserDashboardPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{activeReg && (
|
{activeReg && (
|
||||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => setActiveRegId(null)}>
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => { setEditMode(false); setEditError(null); setShowCancelConfirm(false); setActiveRegId(null); }}>
|
||||||
<div className="bg-white rounded-lg shadow-lg max-w-xl w-full mx-4 p-5 max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
<div className="bg-white rounded-lg shadow-lg max-w-xl w-full mx-4 p-5 max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-lg font-semibold">{activeReg.event?.title || activeReg.eventId}</h3>
|
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||||
<button className="px-2.5 py-1 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => { setEditMode(false); setShowCancelConfirm(false); setActiveRegId(null); }}>Close</button>
|
<span>{activeReg.event?.title || activeReg.eventId}</span>
|
||||||
|
<EventStatusBadge event={activeReg.event} />
|
||||||
|
</h3>
|
||||||
|
<button className="px-2.5 py-1 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => { setEditMode(false); setEditError(null); setShowCancelConfirm(false); setActiveRegId(null); }}>Close</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -847,7 +880,7 @@ export default function UserDashboardPage() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="font-medium mb-1 flex items-center justify-between">
|
<div className="font-medium mb-1 flex items-center justify-between">
|
||||||
<span>Registration items</span>
|
<span>Registration items</span>
|
||||||
{!editMode && canEditActive && (
|
{!editMode && canModifyActive && (
|
||||||
<button disabled={editLoading} onClick={beginEdit} className="px-2.5 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50">Edit</button>
|
<button disabled={editLoading} onClick={beginEdit} className="px-2.5 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50">Edit</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -870,6 +903,7 @@ export default function UserDashboardPage() {
|
|||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
<div className="border rounded p-3 space-y-2 bg-gray-50">
|
<div className="border rounded p-3 space-y-2 bg-gray-50">
|
||||||
|
{editError && <p className="text-xs text-red-600">{editError}</p>}
|
||||||
{editLoading ? (
|
{editLoading ? (
|
||||||
<div className="text-sm text-gray-600">Loading options…</div>
|
<div className="text-sm text-gray-600">Loading options…</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -960,7 +994,7 @@ export default function UserDashboardPage() {
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button onClick={() => setEditMode(false)} className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200">Cancel</button>
|
<button onClick={() => { setEditMode(false); setEditError(null); }} className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200">Cancel</button>
|
||||||
<button onClick={saveEdit} className="px-2.5 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700">Save</button>
|
<button onClick={saveEdit} className="px-2.5 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700">Save</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -986,7 +1020,7 @@ export default function UserDashboardPage() {
|
|||||||
onClick={() => router.push(`/dashboard/user/forms?registrationId=${encodeURIComponent(activeReg.id)}`)}
|
onClick={() => router.push(`/dashboard/user/forms?registrationId=${encodeURIComponent(activeReg.id)}`)}
|
||||||
>Attendee forms</button>
|
>Attendee forms</button>
|
||||||
)}
|
)}
|
||||||
{activeBill && activeBill.outstanding > 0 ? (
|
{canModifyActive && activeBill && activeBill.outstanding > 0 ? (
|
||||||
<button
|
<button
|
||||||
className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1"
|
className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1"
|
||||||
onClick={() => router.push(`/dashboard/user/pay?registrationId=${encodeURIComponent(activeReg.id)}`)}
|
onClick={() => router.push(`/dashboard/user/pay?registrationId=${encodeURIComponent(activeReg.id)}`)}
|
||||||
@@ -1006,8 +1040,8 @@ export default function UserDashboardPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cancel registration — only shown when no payments have been made */}
|
{/* Cancel registration — only shown when no payments have been made and the event still permits changes */}
|
||||||
{activeReg.status !== 'cancelled' && (activeBill?.totalPaid ?? 0) === 0 && (
|
{canModifyActive && activeReg.status !== 'cancelled' && (activeBill?.totalPaid ?? 0) === 0 && (
|
||||||
<div className="pt-3 border-t mt-1">
|
<div className="pt-3 border-t mt-1">
|
||||||
{!showCancelConfirm ? (
|
{!showCancelConfirm ? (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -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