"use client"; import React, { useEffect, useMemo, useState } from "react"; import { useAuth } from "@/hooks/useAuth"; import { apiFetch } from "@/lib/api"; import { useRouter } from "next/navigation"; import { formatDate } from "@/lib/date"; 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'); // 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 // a registration's priceSnapshot — mirrors the pricing logic in register/[eventId]/RegisterForm.tsx. function effectiveEventOptionUnitPrice(eo: any, variantId: string | null): number { const variant = variantId ? (eo?.variants || []).find((v: any) => v.id === variantId) : null; const base = (variant && variant.price !== null && variant.price !== undefined) ? Number(variant.price) : Number(eo?.price || 0); const allTiers = Array.isArray(eo?.earlyBirdTiers) ? eo.earlyBirdTiers : []; const tiers = variantId ? allTiers.filter((t: any) => t.variantId === variantId) : allTiers.filter((t: any) => !t.variantId); if (tiers.length === 0) return base; const now = new Date(); const applicable = tiers .map((t: any) => ({ ...t, _d: new Date(t.deadline) })) .filter((t: any) => now < t._d) .sort((a: any, b: any) => a.price - b.price || a._d.getTime() - b._d.getTime()); return applicable.length > 0 ? applicable[0].price : base; } export default function UserDashboardPage() { const { token, user } = useAuth(); const [registrations, setRegistrations] = useState([]); const [tickets, setTickets] = useState([]); const [error, setError] = useState(null); const [info, setInfo] = useState(null); const [loading, setLoading] = useState(false); // Filters const [showPast, setShowPast] = useState(false); // Billing map: regId -> { totalDue, totalPaid, outstanding, payments } const [billing, setBilling] = useState>({}); // Forms status cache for registrations // regId -> { hasForm: boolean; isRequired?: boolean; submittedCount?: number; requiredCount?: number; complete?: boolean } const [formStatuses, setFormStatuses] = useState>({}); // Ticket selection const [selected, setSelected] = useState>({}); // Registration details modal const [activeRegId, setActiveRegId] = useState(null); const [dialog, setDialog] = useState<{ open: boolean; message: string; loading?: boolean }>({ open: false, message: "", loading: false }); // Track whether the active registration's event has attendee forms const [activeEventHasForm, setActiveEventHasForm] = useState(null); // Compute effective unit price for a registration option. // Uses priceSnapshot when available (authoritative backend price, variant-aware). // Falls back to deadline-only early-bird calculation for legacy rows without a snapshot. const optionUnitPrice = (opt: any, referenceTime: any, atTime: Date): number => { if (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) { return Number(opt.priceSnapshot); } const eo = opt.eventOption; const variantId: string | null = opt.variantId || null; // Use variant price as base if available, otherwise option base const base = (opt.variant?.price !== null && opt.variant?.price !== undefined) ? Number(opt.variant.price) : Number(eo?.price || 0); const allTiers = Array.isArray(eo?.earlyBirdTiers) ? eo.earlyBirdTiers.slice() : []; // Filter: variant options use only their variant-specific tiers; plain options use option-level tiers const tiers = variantId ? allTiers.filter((t: any) => t.variantId === variantId) : allTiers.filter((t: any) => !t.variantId); if (tiers.length === 0) return base; const t = atTime ? new Date(atTime) : new Date(); const ref = referenceTime ? new Date(referenceTime) : t; const applicable = tiers .map((x: any) => ({ ...x, deadline: new Date(x.deadline) })) .filter((x: any) => (ref < x.deadline) && (t < x.deadline)) .sort((a: any, b: any) => a.deadline.getTime() - b.deadline.getTime() || (a.order||0) - (b.order||0) || a.price - b.price); if (applicable.length === 0) return base; const price = Number(applicable[0].price); return (price >= 0) ? price : base; }; useEffect(() => { (async () => { if (!token) return; setLoading(true); setError(null); try { // Tickets don't depend on registrations, so fetch both at once instead of // waterfalling — this was previously fetched only after registrations + all // their payments had already resolved. const [myRegs, myTicks] = await Promise.all([ apiFetch(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token }), apiFetch("/api/tickets/mytickets", { authToken: token }), ]); setRegistrations(myRegs); setTickets(myTicks); // Compute totalDue from priceSnapshot (authoritative backend price, variant-aware). // priceSnapshot is set at registration time and refreshed before each payment. const now = new Date(); const totals: Record = {}; for (const r of myRegs) { const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0); totals[r.id] = { totalDue, totalPaid: 0, outstanding: totalDue, payments: [] }; } setBilling(totals); // Fetch payments per registration to compute outstanding await Promise.all( myRegs.map(async (r: any) => { try { const pays = await apiFetch(`/api/payments/registration/${encodeURIComponent(r.id)}`, { authToken: token }); const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0); // totalDue uses priceSnapshot — not time-dependent, no need to recompute per payment time const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0); const outstanding = Math.max(0, totalDue - totalPaid); setBilling(prev => ({ ...prev, [r.id]: { totalDue, totalPaid, outstanding, payments: pays, }, })); } catch (e) { // Ignore per-reg payment errors but log console.warn("Failed to load payments for", r.id, e); } }) ); } catch (e: any) { setError(e?.message || "Failed to load data"); } finally { setLoading(false); } })(); }, [token, showPast]); // After registrations load, compute forms status for each registration. // `myregistrations` now embeds event.form (with fields) directly, so this no longer // needs a GET /api/events/:id per registration — only the registration-detail fetch // (to get submittedCount) remains, and only for registrations that actually have a form. useEffect(() => { (async () => { if (!token || registrations.length === 0) return; const next: Record = {}; await Promise.all( registrations.map(async (r: any) => { try { const ev = r.event; const hasForm = !!(ev?.form && Array.isArray(ev.form.fields) && ev.form.fields.length > 0); if (!hasForm) { next[r.id] = { hasForm: false }; return; } // Load registration to get number of submitted responses const regFull = await apiFetch(`/api/registrations/${encodeURIComponent(r.id)}`, { authToken: token }); const submittedCount = (regFull?.formResponses || []).length; // Required count equals the number of main tickets purchased const requiredCount = (r.registrationOptions || []) .filter((ro: any) => ro.eventOption?.isMainTicket) .reduce((s: number, ro: any) => s + (ro.quantity || 0), 0); const complete = submittedCount >= requiredCount && requiredCount > 0 ? true : (submittedCount >= requiredCount); next[r.id] = { hasForm: true, isRequired: !!ev?.form?.isRequired, submittedCount, requiredCount, complete, }; } catch (e) { // On any error, do not show forms status to avoid noisy UI next[r.id] = { hasForm: false }; } }) ); setFormStatuses(next); })(); }, [token, registrations]); const toggleSelect = (id: string, checked?: boolean) => { setSelected(prev => ({ ...prev, [id]: checked ?? !prev[id] })); }; const selectAll = (on: boolean) => { const next: Record = {}; tickets.forEach(t => (next[t.id] = on)); setSelected(next); }; const selectedIds = useMemo(() => Object.entries(selected).filter(([, v]) => v).map(([k]) => k), [selected]); const emailTickets = async (ticketIds: string[]) => { if (!token || ticketIds.length === 0) return; setError(null); setInfo(null); // Open loading dialog immediately setDialog({ open: true, message: "Sending tickets…", loading: true }); try { const res: any = await apiFetch("/api/tickets/email", { method: "POST", body: { ticketIds }, authToken: token, }); const msg = (res && res.message) ? res.message : `${ticketIds.length} ticket(s) emailed successfully.`; setDialog({ open: true, message: msg, loading: false }); } catch (e: any) { setDialog({ open: false, message: "", loading: false }); setError(e?.message || "Failed to email tickets"); } }; const emailRegistration = async (registrationId: string) => { if (!token) return; setError(null); setInfo(null); // Show loading dialog while sending setDialog({ open: true, message: "Sending tickets…", loading: true }); try { const res: any = await apiFetch("/api/tickets/email", { method: "POST", body: { registrationId }, authToken: token, }); const msg = (res && res.message) ? res.message : `Tickets for registration emailed successfully.`; setDialog({ open: true, message: msg, loading: false }); } catch (e: any) { setDialog({ open: false, message: "", loading: false }); setError(e?.message || "Failed to email registration tickets"); } }; const whatsappTickets = async (ticketIds: string[]) => { if (!token || ticketIds.length === 0) return; if (!user?.phoneNumber) { setError("No phone number on your account. Add one in your profile."); return; } setError(null); setInfo(null); setDialog({ open: true, message: "Sending to WhatsApp…", loading: true }); try { const res: any = await apiFetch("/api/tickets/email", { method: "POST", body: { ticketIds, channel: "whatsapp" }, authToken: token, }); const msg = (res && res.message) ? res.message : `${ticketIds.length} ticket(s) sent to WhatsApp.`; setDialog({ open: true, message: msg, loading: false }); } catch (e: any) { setDialog({ open: false, message: "", loading: false }); setError(e?.message || "Failed to send tickets to WhatsApp"); } }; const whatsappRegistration = async (registrationId: string) => { if (!token) return; if (!user?.phoneNumber) { setError("No phone number on your account. Add one in your profile."); return; } setError(null); setInfo(null); setDialog({ open: true, message: "Sending to WhatsApp…", loading: true }); try { const res: any = await apiFetch("/api/tickets/email", { method: "POST", body: { registrationId, channel: "whatsapp" }, authToken: token, }); const msg = (res && res.message) ? res.message : `Tickets sent to WhatsApp.`; setDialog({ open: true, message: msg, loading: false }); } catch (e: any) { setDialog({ open: false, message: "", loading: false }); setError(e?.message || "Failed to send tickets to WhatsApp"); } }; // Print helpers const buildTicketHtmlCard = (t: any, buyerName?: string) => { const eventTitle = t.event?.title || t.eventId || "Event"; const eventDate = t.event?.startDate ? new Date(t.event.startDate) : null; const optionName = t.registrationOption?.eventOption?.name || "Ticket"; const variantName = t.registrationOption?.variant?.name; const type = variantName ? `${optionName} — ${variantName}` : optionName; const qty = t.quantity || 1; const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(t.qrCode || t.id)}`; const dateStr = eventDate ? formatDate(eventDate) : ''; return `
${eventTitle}
${dateStr ? `
${dateStr}
` : ''}
${type}
Qty: ${qty}
QR
Ticket ID: ${t.id}
${buyerName ? `
Purchased by: ${buyerName}
` : ''}
`; }; const openPrintWindow = (title: string, cardsHtml: string) => { const w = window.open("", "_blank"); if (!w) return; w.document.write(`${title}
${cardsHtml}
`); w.document.close(); w.focus(); }; const printTickets = (ticketList: any[]) => { if (!ticketList || ticketList.length === 0) return; const cards = ticketList.map(t => buildTicketHtmlCard(t, user?.name)).join(""); openPrintWindow(`Tickets (${ticketList.length})`, cards); }; // Upcoming events derived from registrations const upcomingEvents = useMemo(() => { const map = new Map(); for (const r of registrations) { if (r.event && isFuture(r.event.startDate)) { if (!map.has(r.event.id)) map.set(r.event.id, r.event); } } return Array.from(map.values()).sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()); }, [registrations]); // Tickets by grouping const ticketsByEvent = useMemo(() => { const m: Record = {}; for (const t of tickets) { const eid = t.eventId; if (!m[eid]) m[eid] = []; m[eid].push(t); } return m; }, [tickets]); // Registration click -> open modal const activeReg = useMemo(() => registrations.find(r => r.id === activeRegId) || null, [registrations, activeRegId]); const activeBill = activeRegId ? billing[activeRegId] : undefined; // When a registration is opened, determine whether its event has attendee forms useEffect(() => { setActiveEventHasForm(null); if (!activeRegId) return; const ar = registrations.find(r => r.id === activeRegId); if (!ar) return; const evForm = ar.event?.form; if (evForm && Array.isArray(evForm.fields) && evForm.fields.length > 0) { setActiveEventHasForm(true); return; } (async () => { try { const ev = await apiFetch(`/api/events/${encodeURIComponent(ar.eventId)}`); const has = !!(ev?.form && Array.isArray(ev.form.fields) && ev.form.fields.length > 0); setActiveEventHasForm(has); } catch { setActiveEventHasForm(false); } })(); }, [activeRegId, registrations]); const showFormsButton = useMemo(() => { const evForm = activeReg?.event?.form; if (evForm && Array.isArray(evForm.fields)) return evForm.fields.length > 0; if (activeEventHasForm !== null) return activeEventHasForm; return false; }, [activeReg, activeEventHasForm]); // Cancel registration state const [cancelLoading, setCancelLoading] = useState(false); const [showCancelConfirm, setShowCancelConfirm] = useState(false); const cancelRegistration = async () => { if (!token || !activeRegId) return; setCancelLoading(true); try { await apiFetch(`/api/registrations/${encodeURIComponent(activeRegId)}`, { method: 'DELETE', authToken: token, }); const myRegs = await apiFetch(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token }); setRegistrations(myRegs); setActiveRegId(null); setShowCancelConfirm(false); setInfo('Registration cancelled.'); } catch (e: any) { setError(e?.message || 'Failed to cancel registration'); setShowCancelConfirm(false); } finally { setCancelLoading(false); } }; // Editor state for registration items. Keyed like register/[eventId]/RegisterForm.tsx: // "eventOptionId" for plain options, "eventOptionId:variantId" for variant options. const [editMode, setEditMode] = useState(false); const [editLoading, setEditLoading] = useState(false); const [editEventOptions, setEditEventOptions] = useState([]); const [editQuantities, setEditQuantities] = useState>({}); const [editBaseQty, setEditBaseQty] = useState>({}); const [editMinQty, setEditMinQty] = useState>({}); const [editError, setEditError] = useState(null); // 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; return !isEventOver(activeReg.event); }, [activeReg]); const editTotal = useMemo(() => { return editEventOptions.reduce((sum: number, eo: any) => { if (Array.isArray(eo.variants) && eo.variants.length > 0) { return sum + eo.variants.reduce((vsum: number, v: any) => { const qty = editQuantities[`${eo.id}:${v.id}`] || 0; return vsum + qty * effectiveEventOptionUnitPrice(eo, v.id); }, 0); } return sum + (editQuantities[eo.id] || 0) * effectiveEventOptionUnitPrice(eo, null); }, 0); }, [editEventOptions, editQuantities]); const beginEdit = async () => { if (!activeReg || !token) return; setEditError(null); setEditLoading(true); try { // Load event options (with variants) for this event const eventData = await apiFetch(`/api/events/${encodeURIComponent(activeReg.eventId)}`); const eventOptions = (eventData?.eventOptions || []) as any[]; const regOptById = new Map((activeReg.registrationOptions || []).map((o: any) => [o.id, o])); // Prefill quantities from the current registration const qtyMap: Record = {}; for (const ro of (activeReg.registrationOptions || [])) { const key = ro.variantId ? `${ro.eventOptionId}:${ro.variantId}` : ro.eventOptionId; qtyMap[key] = (qtyMap[key] || 0) + (ro.quantity || 0); } // Already-issued ticket quantity per option/variant — can't reduce below this // (tickets are never deleted or shrunk, only ever grown, once paid). const minQtyMap: Record = {}; for (const t of tickets) { const ro = regOptById.get(t.registrationOptionId); if (!ro) continue; const key = ro.variantId ? `${ro.eventOptionId}:${ro.variantId}` : ro.eventOptionId; minQtyMap[key] = (minQtyMap[key] || 0) + (t.quantity || 0); } // Ensure every option/variant has a (possibly zero) entry so they all render eventOptions.forEach((eo: any) => { if (Array.isArray(eo.variants) && eo.variants.length > 0) { eo.variants.forEach((v: any) => { const key = `${eo.id}:${v.id}`; if (!(key in qtyMap)) qtyMap[key] = 0; }); } else if (!(eo.id in qtyMap)) { qtyMap[eo.id] = 0; } }); setEditEventOptions(eventOptions); setEditQuantities(qtyMap); setEditBaseQty({ ...qtyMap }); setEditMinQty(minQtyMap); setEditMode(true); } catch (e) { setEditError((e as any)?.message || 'Failed to load event options'); } finally { setEditLoading(false); } }; const saveEdit = async () => { if (!activeReg || !token) return; setEditError(null); for (const [key, minQty] of Object.entries(editMinQty)) { if (minQty <= 0) continue; if ((editQuantities[key] || 0) < minQty) { const [eventOptionId, variantId] = key.includes(':') ? key.split(':') : [key, undefined]; 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'); setEditError(`Cannot reduce "${label}" below the ${minQty} already issued`); return; } } const payloadOptions: { eventOptionId: string; quantity: number; variantId?: string }[] = []; for (const [key, qty] of Object.entries(editQuantities)) { if (qty <= 0) continue; if (key.includes(':')) { const [eventOptionId, variantId] = key.split(':'); payloadOptions.push({ eventOptionId, quantity: qty, variantId }); } else { payloadOptions.push({ eventOptionId: key, quantity: qty }); } } if (payloadOptions.length === 0) { setEditError('Please select at least one item'); return; } // Enforce client-side: do not reduce below paid const paid = activeBill?.totalPaid || 0; if (editTotal < paid) { setEditError('Cannot reduce items below the amount already paid'); return; } setLoading(true); try { await apiFetch(`/api/registrations/${encodeURIComponent(activeReg.id)}/options`, { method: 'PUT', authToken: token, body: { options: payloadOptions }, }); // Reload registrations to reflect changes const myRegs = await apiFetch(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token }); setRegistrations(myRegs); try { const myTicks = await apiFetch("/api/tickets/mytickets", { authToken: token }); setTickets(myTicks); } catch {} // Recompute billing totals using priceSnapshot const now2 = new Date(); const totals: Record = {}; for (const r of myRegs) { const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now2) * (opt.quantity || 0), 0); totals[r.id] = { totalDue, totalPaid: 0, outstanding: totalDue, payments: [] }; } setBilling(totals); // Payments will be refreshed lazily or on next open; fetch for this one immediately try { const pays = await apiFetch(`/api/payments/registration/${encodeURIComponent(activeReg.id)}`, { authToken: token }); const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0); const totalDue = totals[activeReg.id]?.totalDue ?? 0; setBilling(prev => ({ ...prev, [activeReg.id]: { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid), payments: pays, } })); } catch {} setInfo('Registration updated successfully'); setEditMode(false); } catch (e: any) { setEditError(e?.message || 'Failed to update registration'); } finally { setLoading(false); } }; const router = useRouter(); return (

Welcome{user ? `, ${user.name}` : ""}

{error &&

{error}

} {info &&

{info}

} {loading &&

Loading…

}

My Registrations

Click on registration to edit or make payment

{registrations.length === 0 ? (

No registrations yet.

) : (
    {registrations // Hide cancelled always; hide past unless showPast .filter((r: any) => r.status !== 'cancelled') .filter((r: any) => showPast || !isEventOver(r.event)) .map((r: any) => { const bill = billing[r.id]; const isCancelled = r.status === 'cancelled'; const outstanding = isCancelled ? 0 : (bill?.outstanding ?? 0); const totalDue = bill?.totalDue ?? 0; return (
  • setActiveRegId(r.id)} >
    {r.event?.title || r.eventId}
    Status: {r.status}{isCancelled && cancelled}
    {(() => { const fs = formStatuses[r.id]; if (!fs || !fs.hasForm) return null; const label = fs.complete ? 'Complete' : 'Incomplete'; const colorClass = fs.complete ? 'bg-green-100 text-green-800' : (fs.isRequired ? 'bg-red-100 text-red-800' : 'bg-yellow-100 text-yellow-800'); return (
    Forms: {label}
    ); })()} {(() => { if (r.status == 'paid') return null; // Early-bird notice: only for events with tiers that haven't passed const allTiers = (r.registrationOptions || []).flatMap((ro: any) => Array.isArray(ro.eventOption?.earlyBirdTiers) ? ro.eventOption.earlyBirdTiers : []); const upcoming = allTiers .map((t: any) => ({ ...t, deadline: new Date(t.deadline) })) .filter((t: any) => new Date() < t.deadline) .sort((a: any, b: any) => a.deadline.getTime() - b.deadline.getTime()); if (upcoming.length === 0) return null; const nextDeadline = upcoming[0].deadline; const pays = (billing[r.id]?.payments || []); const lastPaymentAt = pays.length > 0 ? new Date(Math.max(...pays.map((p: any) => new Date(p.createdAt).getTime()))) : null; const now = new Date(); const priceNow = (r.registrationOptions || []).reduce((s: number, ro: any) => s + optionUnitPrice(ro, lastPaymentAt, now) * (ro.quantity || 0), 0); const afterPrice = (r.registrationOptions || []).reduce((s: number, ro: any) => s + optionUnitPrice(ro, lastPaymentAt, new Date(nextDeadline.getTime() + 1000)) * (ro.quantity || 0), 0); const dateStr = nextDeadline.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: '2-digit' }); if (afterPrice > priceNow) { return (
    Early bird: R {priceNow.toFixed(2)} if paid before {dateStr}. Price increases to R {afterPrice.toFixed(2)} thereafter.
    ); } return (
    Early bird pricing in effect until {dateStr}.
    ); })()}
    {isCancelled ? (
    No outstanding — registration cancelled
    ) : ( <>
    Outstanding: 0 ? "text-red-600" : "text-green-700"}>{formatRand(outstanding)}
    Total: {formatRand(totalDue)}
    )}
  • ); })}
)}

Upcoming Events

{upcomingEvents.length === 0 ? (

No upcoming events.

) : (
    {upcomingEvents.map(ev => (
  • {ev.title}
    {new Date(ev.startDate).toLocaleString()} - {new Date(ev.endDate).toLocaleString()}
    {user?.phoneNumber && ( )}
  • ))}
)}

My Tickets

{tickets.length > 0 && (
{user?.phoneNumber && ( )}
)}
{tickets.filter(t => showPast || !isEventOver(t.event)).length === 0 ? (

No tickets yet.

) : (
    {tickets .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"; const variantName = t.registrationOption?.variant?.name; const type = variantName ? `${optionName} — ${variantName}` : optionName; return (
  • {eventDate && {formatDate(eventDate)}}
    {type}
    Qty: {t.quantity || 1}
    #{t.id.slice(0, 8)}
    {user?.phoneNumber && ( )}
  • ); })}
)}
{activeReg && (
{ setEditMode(false); setEditError(null); setShowCancelConfirm(false); setActiveRegId(null); }}>
e.stopPropagation()}>

{activeReg.event?.title || activeReg.eventId}

Status: {activeReg.status}
{activeBill && (
Total: {formatRand(activeBill.totalDue)}
Paid: {formatRand(activeBill.totalPaid)}
Outstanding: 0 ? "text-red-600" : "text-green-700"}>{formatRand(activeBill.outstanding)}
)}
Registration items {!editMode && canModifyActive && ( )}
{!editMode ? (
    {(activeReg.registrationOptions || []).map((opt: any) => { const variantLabel = opt.variant?.name ? ` (${opt.variant.name})` : ''; const unitPrice = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) ? Number(opt.priceSnapshot) : (opt.variant?.price ?? opt.eventOption?.price ?? 0); return (
  • {opt.eventOption?.name}{variantLabel} x {opt.quantity} — {formatRand(unitPrice * (opt.quantity || 0))} {unitPrice < (opt.eventOption?.price || 0) && ( (early bird) )}
  • ); })}
) : (
{editError &&

{editError}

} {editLoading ? (
Loading options…
) : ( <>
{editEventOptions.map((eo: any) => { const hasVariants = Array.isArray(eo.variants) && eo.variants.length > 0; const renderQtyInput = (key: string, unitPrice: number, stockLimit?: number, availableCount?: number) => { const qty = editQuantities[key] || 0; const minQty = editMinQty[key] || 0; const baseQty = editBaseQty[key] || 0; const maxQty = ((stockLimit ?? 0) > 0 && availableCount !== undefined) ? availableCount + baseQty : undefined; return (
{ let v = Math.max(minQty, parseInt(e.target.value || '0', 10) || 0); if (maxQty !== undefined) v = Math.min(v, maxQty); setEditQuantities(prev => ({ ...prev, [key]: v })); }} className="w-20 border rounded px-2 py-1 text-sm" />
); }; if (hasVariants) { const variants = eo.variants.slice().sort((a: any, b: any) => (a.order || 0) - (b.order || 0)); return (
{eo.name}
    {variants.map((v: any) => { const key = `${eo.id}:${v.id}`; const minQty = editMinQty[key] || 0; const unitPrice = effectiveEventOptionUnitPrice(eo, v.id); return (
  • {v.name}
    {formatRand(unitPrice)}
    {minQty > 0 && (
    {minQty} already issued
    )}
    {renderQtyInput(key, unitPrice, v.stockLimit, v.availableCount)}
  • ); })}
); } const key = eo.id; const minQty = editMinQty[key] || 0; const unitPrice = effectiveEventOptionUnitPrice(eo, null); return (
{eo.name}
{formatRand(unitPrice)}
{minQty > 0 && (
{minQty} already issued
)}
{renderQtyInput(key, unitPrice, eo.stockLimit, eo.availableCount)}
); })}
{(() => { const paid = activeBill?.totalPaid || 0; const hasAnyQty = Object.values(editQuantities).some(q => q > 0); const invalid = editTotal < paid || !hasAnyQty; return ( <>
New total: {formatRand(editTotal)}
{paid > 0 &&
Paid: {formatRand(paid)}
} {invalid &&
Total cannot be below amount already paid and at least one item is required.
} ); })()}
)}
)}
{activeBill && activeBill.payments.length > 0 && (
Payments
    {activeBill.payments.map((p: any) => (
  • {new Date(p.createdAt).toLocaleString()} — {formatRand(p.amount)} ({p.method || "Payment"})
  • ))}
)}
{showFormsButton && ( )} {canModifyActive && activeBill && activeBill.outstanding > 0 ? ( ) : ( <> {user?.phoneNumber && ( )} )}
{/* Cancel registration — only shown when no payments have been made and the event still permits changes */} {canModifyActive && activeReg.status !== 'cancelled' && (activeBill?.totalPaid ?? 0) === 0 && (
{!showCancelConfirm ? ( ) : (
Are you sure you want to cancel? This cannot be undone.
)}
)}
)} {dialog.open && (
{ if (!dialog.loading) setDialog({ open: false, message: "", loading: false }); }} >
e.stopPropagation()}> {dialog.loading ? (
Sending tickets…

Please wait while we send your tickets.

) : ( <>
Tickets sent

{dialog.message}

)}
)}
); }