Fix user dashboard: buried edit errors, closed events not hidden, closed/past registrations editable

Registration-edit errors were set into a page-level error state rendered
behind the edit modal overlay; they now render inside the modal. The
"Show past events" toggle only hid date-based past events, letting
cashup-closed events leak through by default; a shared isEventOver()
check now covers both, applied to registrations, tickets, and the
Edit button visibility (mirroring the backend's own edit block).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 16:43:33 +02:00
parent 890970fffe
commit eb46ed8264
3 changed files with 24 additions and 18 deletions
+5
View File
@@ -7,6 +7,11 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [Unreleased]
### 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 button no longer appears for closed or past registrations.
## [1.0.0] - 2026-07-23 ## [1.0.0] - 2026-07-23
### Added ### Added
@@ -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({
+18 -17
View File
@@ -9,6 +9,8 @@ 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');
// 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 +445,11 @@ 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(() => { const canEditActive = 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 +466,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 +510,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 +518,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 +527,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 +543,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 +592,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);
} }
@@ -635,11 +639,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';
@@ -786,12 +786,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";
@@ -827,11 +827,11 @@ 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">{activeReg.event?.title || activeReg.eventId}</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); setShowCancelConfirm(false); setActiveRegId(null); }}>Close</button> <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>
@@ -870,6 +870,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 +961,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>