|
|
|
@@ -9,6 +9,25 @@ import { QrImage } from "@/components/shared/QrImage";
|
|
|
|
|
// Helper formatters
|
|
|
|
|
const formatRand = (n: number) => `R ${n.toFixed(2)}`;
|
|
|
|
|
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.
|
|
|
|
|
// 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 [editBaseQty, setEditBaseQty] = 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;
|
|
|
|
|
const end = activeReg.event?.endDate ? new Date(activeReg.event.endDate).getTime() : Infinity;
|
|
|
|
|
return end >= Date.now();
|
|
|
|
|
return !isEventOver(activeReg.event);
|
|
|
|
|
}, [activeReg]);
|
|
|
|
|
|
|
|
|
|
const editTotal = useMemo(() => {
|
|
|
|
@@ -464,6 +485,7 @@ export default function UserDashboardPage() {
|
|
|
|
|
|
|
|
|
|
const beginEdit = async () => {
|
|
|
|
|
if (!activeReg || !token) return;
|
|
|
|
|
setEditError(null);
|
|
|
|
|
setEditLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
// Load event options (with variants) for this event
|
|
|
|
@@ -507,7 +529,7 @@ export default function UserDashboardPage() {
|
|
|
|
|
setEditMinQty(minQtyMap);
|
|
|
|
|
setEditMode(true);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
setError((e as any)?.message || 'Failed to load event options');
|
|
|
|
|
setEditError((e as any)?.message || 'Failed to load event options');
|
|
|
|
|
} finally {
|
|
|
|
|
setEditLoading(false);
|
|
|
|
|
}
|
|
|
|
@@ -515,6 +537,7 @@ export default function UserDashboardPage() {
|
|
|
|
|
|
|
|
|
|
const saveEdit = async () => {
|
|
|
|
|
if (!activeReg || !token) return;
|
|
|
|
|
setEditError(null);
|
|
|
|
|
|
|
|
|
|
for (const [key, minQty] of Object.entries(editMinQty)) {
|
|
|
|
|
if (minQty <= 0) continue;
|
|
|
|
@@ -523,7 +546,7 @@ export default function UserDashboardPage() {
|
|
|
|
|
const eo = editEventOptions.find((o: any) => o.id === eventOptionId);
|
|
|
|
|
const variant = variantId ? (eo?.variants || []).find((v: any) => v.id === variantId) : null;
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
@@ -539,13 +562,13 @@ export default function UserDashboardPage() {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (payloadOptions.length === 0) {
|
|
|
|
|
setError('Please select at least one item');
|
|
|
|
|
setEditError('Please select at least one item');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// Enforce client-side: do not reduce below paid
|
|
|
|
|
const paid = activeBill?.totalPaid || 0;
|
|
|
|
|
if (editTotal < paid) {
|
|
|
|
|
setError('Cannot reduce items below the amount already paid');
|
|
|
|
|
setEditError('Cannot reduce items below the amount already paid');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setLoading(true);
|
|
|
|
@@ -588,7 +611,7 @@ export default function UserDashboardPage() {
|
|
|
|
|
setInfo('Registration updated successfully');
|
|
|
|
|
setEditMode(false);
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
setError(e?.message || 'Failed to update registration');
|
|
|
|
|
setEditError(e?.message || 'Failed to update registration');
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
@@ -635,11 +658,7 @@ export default function UserDashboardPage() {
|
|
|
|
|
{registrations
|
|
|
|
|
// Hide cancelled always; hide past unless showPast
|
|
|
|
|
.filter((r: any) => r.status !== 'cancelled')
|
|
|
|
|
.filter((r: any) => {
|
|
|
|
|
if (showPast) return true;
|
|
|
|
|
const end = r.event?.endDate ? new Date(r.event.endDate).getTime() : Infinity;
|
|
|
|
|
return end >= Date.now();
|
|
|
|
|
})
|
|
|
|
|
.filter((r: any) => showPast || !isEventOver(r.event))
|
|
|
|
|
.map((r: any) => {
|
|
|
|
|
const bill = billing[r.id];
|
|
|
|
|
const isCancelled = r.status === 'cancelled';
|
|
|
|
@@ -653,7 +672,10 @@ export default function UserDashboardPage() {
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-start justify-between gap-3">
|
|
|
|
|
<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">
|
|
|
|
|
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>
|
|
|
|
@@ -726,7 +748,10 @@ export default function UserDashboardPage() {
|
|
|
|
|
<ul className="grid md:grid-cols-2 gap-3">
|
|
|
|
|
{upcomingEvents.map(ev => (
|
|
|
|
|
<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="flex flex-wrap gap-2 mt-2">
|
|
|
|
|
<button
|
|
|
|
@@ -786,12 +811,12 @@ export default function UserDashboardPage() {
|
|
|
|
|
</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>
|
|
|
|
|
) : (
|
|
|
|
|
<ul className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
|
|
|
{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) => {
|
|
|
|
|
const eventDate = t.event?.startDate ? new Date(t.event.startDate) : null;
|
|
|
|
|
const optionName = t.registrationOption?.eventOption?.name || "Ticket";
|
|
|
|
@@ -804,6 +829,7 @@ export default function UserDashboardPage() {
|
|
|
|
|
<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>
|
|
|
|
|
</label>
|
|
|
|
|
<EventStatusBadge event={t.event} />
|
|
|
|
|
{eventDate && <span className="text-xs text-gray-500">{formatDate(eventDate)}</span>}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-sm text-gray-700">{type}</div>
|
|
|
|
@@ -827,11 +853,14 @@ export default function UserDashboardPage() {
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
{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="flex items-center justify-between mb-3">
|
|
|
|
|
<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>
|
|
|
|
|
<h3 className="text-lg font-semibold flex items-center gap-2">
|
|
|
|
|
<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 className="space-y-3">
|
|
|
|
|
<div>
|
|
|
|
@@ -847,7 +876,7 @@ export default function UserDashboardPage() {
|
|
|
|
|
<div>
|
|
|
|
|
<div className="font-medium mb-1 flex items-center justify-between">
|
|
|
|
|
<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>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
@@ -870,6 +899,7 @@ export default function UserDashboardPage() {
|
|
|
|
|
</ul>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="border rounded p-3 space-y-2 bg-gray-50">
|
|
|
|
|
{editError && <p className="text-xs text-red-600">{editError}</p>}
|
|
|
|
|
{editLoading ? (
|
|
|
|
|
<div className="text-sm text-gray-600">Loading options…</div>
|
|
|
|
|
) : (
|
|
|
|
@@ -960,7 +990,7 @@ export default function UserDashboardPage() {
|
|
|
|
|
})()}
|
|
|
|
|
</div>
|
|
|
|
|
<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>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
@@ -986,7 +1016,7 @@ export default function UserDashboardPage() {
|
|
|
|
|
onClick={() => router.push(`/dashboard/user/forms?registrationId=${encodeURIComponent(activeReg.id)}`)}
|
|
|
|
|
>Attendee forms</button>
|
|
|
|
|
)}
|
|
|
|
|
{activeBill && activeBill.outstanding > 0 ? (
|
|
|
|
|
{canModifyActive && activeBill && activeBill.outstanding > 0 ? (
|
|
|
|
|
<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"
|
|
|
|
|
onClick={() => router.push(`/dashboard/user/pay?registrationId=${encodeURIComponent(activeReg.id)}`)}
|
|
|
|
@@ -1006,8 +1036,8 @@ export default function UserDashboardPage() {
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Cancel registration — only shown when no payments have been made */}
|
|
|
|
|
{activeReg.status !== 'cancelled' && (activeBill?.totalPaid ?? 0) === 0 && (
|
|
|
|
|
{/* Cancel registration — only shown when no payments have been made and the event still permits changes */}
|
|
|
|
|
{canModifyActive && activeReg.status !== 'cancelled' && (activeBill?.totalPaid ?? 0) === 0 && (
|
|
|
|
|
<div className="pt-3 border-t mt-1">
|
|
|
|
|
{!showCancelConfirm ? (
|
|
|
|
|
<button
|
|
|
|
|