349686e182
Extends the earlier user-dashboard fix: canEditActive is renamed canModifyActive and now also gates the "Make payment" and "Cancel registration" actions, not just editing. Backend enforcement added to cancelRegistration and createYocoCheckout to block past-event self-service payment/cancellation server-side (cashup-closed events were already blocked via assertEventOpen; admins/supervisors are exempt from the past-date check, consistent with existing overrides). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1073 lines
55 KiB
TypeScript
1073 lines
55 KiB
TypeScript
"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<any[]>([]);
|
|
const [tickets, setTickets] = useState<any[]>([]);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [info, setInfo] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState<boolean>(false);
|
|
|
|
// Filters
|
|
const [showPast, setShowPast] = useState<boolean>(false);
|
|
|
|
// Billing map: regId -> { totalDue, totalPaid, outstanding, payments }
|
|
const [billing, setBilling] = useState<Record<string, { totalDue: number; totalPaid: number; outstanding: number; payments: any[] }>>({});
|
|
|
|
// Forms status cache for registrations
|
|
// regId -> { hasForm: boolean; isRequired?: boolean; submittedCount?: number; requiredCount?: number; complete?: boolean }
|
|
const [formStatuses, setFormStatuses] = useState<Record<string, { hasForm: boolean; isRequired?: boolean; submittedCount?: number; requiredCount?: number; complete?: boolean }>>({});
|
|
|
|
// Ticket selection
|
|
const [selected, setSelected] = useState<Record<string, boolean>>({});
|
|
|
|
// Registration details modal
|
|
const [activeRegId, setActiveRegId] = useState<string | null>(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<boolean | null>(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<any[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token }),
|
|
apiFetch<any[]>("/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<string, { totalDue: number; totalPaid: number; outstanding: number; payments: any[] }> = {};
|
|
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<any[]>(`/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<string, { hasForm: boolean; isRequired?: boolean; submittedCount?: number; requiredCount?: number; complete?: boolean }> = {};
|
|
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<any>(`/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<string, boolean> = {};
|
|
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 `
|
|
<div class="ticket">
|
|
<div class="row between">
|
|
<div class="evt">${eventTitle}</div>
|
|
${dateStr ? `<div class="date">${dateStr}</div>` : ''}
|
|
</div>
|
|
<div class="type">${type}</div>
|
|
<div class="qty">Qty: <strong>${qty}</strong></div>
|
|
<div class="qrwrap"><img src="${qrSrc}" alt="QR" /></div>
|
|
<div class="meta">Ticket ID: ${t.id}</div>
|
|
${buyerName ? `<div class="meta">Purchased by: ${buyerName}</div>` : ''}
|
|
</div>
|
|
`;
|
|
};
|
|
|
|
const openPrintWindow = (title: string, cardsHtml: string) => {
|
|
const w = window.open("", "_blank");
|
|
if (!w) return;
|
|
w.document.write(`<!doctype html><html><head><title>${title}</title>
|
|
<style>
|
|
*{box-sizing:border-box}
|
|
body{font-family:Arial,Helvetica,sans-serif;margin:0;padding:16px;background:#f7f7f7}
|
|
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:16px}
|
|
.ticket{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:14px;box-shadow:0 1px 3px rgba(0,0,0,0.06);break-inside:avoid;page-break-inside:avoid}
|
|
.evt{font-weight:700;font-size:14px}
|
|
.type{margin-top:4px;color:#374151}
|
|
.qty{margin-top:2px;font-size:12px;color:#374151}
|
|
.date{color:#6b7280;font-size:12px}
|
|
.row{display:flex;gap:8px;align-items:center}
|
|
.between{justify-content:space-between}
|
|
.qrwrap{display:flex;justify-content:center;align-items:center;margin:10px 0}
|
|
.qrwrap img{width:160px;height:160px}
|
|
.meta{font-size:12px;color:#6b7280;text-align:center;margin-top:6px}
|
|
@media print { body{background:#fff} .print-hidden{display:none !important} }
|
|
</style>
|
|
</head><body>
|
|
<h2 class="print-hidden" style="margin:8px 0 16px 0;font-size:18px">${title}</h2>
|
|
<div class="grid">${cardsHtml}</div>
|
|
<script>
|
|
(function(){
|
|
function printWhenReady(){
|
|
var imgs = Array.prototype.slice.call(document.images);
|
|
if(imgs.length === 0){ window.print(); return; }
|
|
var remaining = imgs.length;
|
|
function done(){ remaining--; if(remaining <= 0){ setTimeout(function(){ window.print(); }, 200); } }
|
|
imgs.forEach(function(img){
|
|
if (img.complete) { done(); }
|
|
else {
|
|
img.addEventListener('load', done, { once: true });
|
|
img.addEventListener('error', done, { once: true });
|
|
}
|
|
});
|
|
}
|
|
if (document.readyState === 'complete') printWhenReady();
|
|
else window.addEventListener('load', printWhenReady);
|
|
})();
|
|
</script>
|
|
</body></html>`);
|
|
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<string, any>();
|
|
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<string, any[]> = {};
|
|
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<any>(`/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<any[]>(`/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<boolean>(false);
|
|
const [editLoading, setEditLoading] = useState<boolean>(false);
|
|
const [editEventOptions, setEditEventOptions] = useState<any[]>([]);
|
|
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);
|
|
|
|
// 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<any>(`/api/events/${encodeURIComponent(activeReg.eventId)}`);
|
|
const eventOptions = (eventData?.eventOptions || []) as any[];
|
|
|
|
const regOptById = new Map<string, any>((activeReg.registrationOptions || []).map((o: any) => [o.id, o]));
|
|
|
|
// Prefill quantities from the current registration
|
|
const qtyMap: Record<string, number> = {};
|
|
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<string, number> = {};
|
|
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<any[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
|
|
setRegistrations(myRegs);
|
|
try {
|
|
const myTicks = await apiFetch<any[]>("/api/tickets/mytickets", { authToken: token });
|
|
setTickets(myTicks);
|
|
} catch {}
|
|
// Recompute billing totals using priceSnapshot
|
|
const now2 = new Date();
|
|
const totals: Record<string, { totalDue: number; totalPaid: number; outstanding: number; payments: any[] }> = {};
|
|
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<any[]>(`/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 (
|
|
<div className="max-w-6xl mx-auto w-full px-4 py-6 sm:px-6">
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-4">
|
|
<h1 className="text-2xl font-semibold">Welcome{user ? `, ${user.name}` : ""}</h1>
|
|
<div className="flex flex-wrap gap-2">
|
|
<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/reset-password")}
|
|
>Reset password</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"
|
|
onClick={() => router.push("/dashboard/user/donate")}
|
|
>Make donation</button>
|
|
</div>
|
|
</div>
|
|
{error && <p className="text-red-600 text-sm mb-3">{error}</p>}
|
|
{info && <p className="text-green-700 text-sm mb-3">{info}</p>}
|
|
{loading && <p className="text-gray-500 text-sm mb-3">Loading…</p>}
|
|
|
|
|
|
<section className="mb-8">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h2 className="text-xl font-semibold">My Registrations</h2>
|
|
<label className="text-sm flex items-center gap-2">
|
|
<input type="checkbox" checked={showPast} onChange={e => setShowPast(e.target.checked)} />
|
|
<span>Show past events</span>
|
|
</label>
|
|
</div>
|
|
<p className="text-sm text-gray-600 mb-3 italic">
|
|
Click on registration to edit or make payment
|
|
</p>
|
|
{registrations.length === 0 ? (
|
|
<p className="text-gray-600 text-sm">No registrations yet.</p>
|
|
) : (
|
|
<ul className="space-y-2">
|
|
{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 (
|
|
<li
|
|
key={r.id}
|
|
className={"border rounded p-3 cursor-pointer " + (isCancelled ? "opacity-60 bg-gray-50" : "hover:bg-gray-50")}
|
|
onClick={() => setActiveRegId(r.id)}
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<div className="font-medium">{r.event?.title || r.eventId}</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>
|
|
{(() => {
|
|
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 (
|
|
<div className="mt-1 text-xs">
|
|
<span className={`inline-block px-1.5 py-0.5 rounded ${colorClass}`}>Forms: {label}</span>
|
|
</div>
|
|
);
|
|
})()}
|
|
{(() => {
|
|
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 (
|
|
<div className="mt-1 text-xs text-amber-700">
|
|
Early bird: R {priceNow.toFixed(2)} if paid before {dateStr}. Price increases to R {afterPrice.toFixed(2)} thereafter.
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div className="mt-1 text-xs text-amber-700">
|
|
Early bird pricing in effect until {dateStr}.
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
<div className="text-right">
|
|
{isCancelled ? (
|
|
<div className="text-xs text-gray-500">No outstanding — registration cancelled</div>
|
|
) : (
|
|
<>
|
|
<div className="text-sm">Outstanding: <span className={outstanding > 0 ? "text-red-600" : "text-green-700"}>{formatRand(outstanding)}</span></div>
|
|
<div className="text-xs text-gray-500">Total: {formatRand(totalDue)}</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
|
|
<section className="mb-8">
|
|
<h2 className="text-xl font-semibold mb-2">Upcoming Events</h2>
|
|
{upcomingEvents.length === 0 ? (
|
|
<p className="text-gray-600 text-sm">No upcoming events.</p>
|
|
) : (
|
|
<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="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
|
|
className="px-3 py-1.5 text-sm 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={() => {
|
|
const list = tickets.filter(t => t.eventId === ev.id);
|
|
printTickets(list);
|
|
}}
|
|
>Print all tickets</button>
|
|
<button
|
|
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
|
|
onClick={() => {
|
|
const ids = tickets.filter(t => t.eventId === ev.id).map(t => t.id);
|
|
emailTickets(ids);
|
|
}}
|
|
>Email all tickets</button>
|
|
{user?.phoneNumber && (
|
|
<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={() => {
|
|
const ids = tickets.filter(t => t.eventId === ev.id).map(t => t.id);
|
|
whatsappTickets(ids);
|
|
}}
|
|
>WhatsApp tickets</button>
|
|
)}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
|
|
<section>
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-2">
|
|
<h2 className="text-xl font-semibold">My Tickets</h2>
|
|
{tickets.length > 0 && (
|
|
<div className="flex flex-wrap gap-2">
|
|
<button className="px-3 py-1.5 text-sm 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={() => selectAll(true)}>Select all</button>
|
|
<button className="px-3 py-1.5 text-sm 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={() => selectAll(false)}>Clear</button>
|
|
<button
|
|
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
|
|
disabled={selectedIds.length === 0}
|
|
onClick={() => printTickets(tickets.filter(t => selectedIds.includes(t.id)))}
|
|
>Print selected</button>
|
|
<button
|
|
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
|
|
disabled={selectedIds.length === 0}
|
|
onClick={() => emailTickets(selectedIds)}
|
|
>Email selected</button>
|
|
{user?.phoneNumber && (
|
|
<button
|
|
className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1"
|
|
disabled={selectedIds.length === 0}
|
|
onClick={() => whatsappTickets(selectedIds)}
|
|
>WhatsApp selected</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{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 || !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 (
|
|
<li key={t.id} className="border rounded-xl p-3 space-y-2 bg-white shadow-sm">
|
|
<div className="flex items-start justify-between">
|
|
<label className="flex items-center gap-2 text-sm">
|
|
<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>
|
|
{eventDate && <span className="text-xs text-gray-500">{formatDate(eventDate)}</span>}
|
|
</div>
|
|
<div className="text-sm text-gray-700">{type}</div>
|
|
<div className="text-xs font-medium text-blue-700 bg-blue-50 rounded px-2 py-0.5 inline-block">
|
|
Qty: {t.quantity || 1}
|
|
</div>
|
|
<div className="flex justify-center"><QrImage value={t.qrCode || t.id} size={120} className="w-28 h-28" /></div>
|
|
<div className="text-center text-xs text-gray-500">#{t.id.slice(0, 8)}</div>
|
|
<div className="flex gap-2 pt-1 flex-wrap">
|
|
<button onClick={() => emailTickets([t.id])} className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1">Email</button>
|
|
{user?.phoneNumber && (
|
|
<button onClick={() => whatsappTickets([t.id])} 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">WhatsApp</button>
|
|
)}
|
|
<button onClick={() => printTickets([t])} className="px-3 py-1.5 text-sm 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">Print</button>
|
|
</div>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
|
|
{activeReg && (
|
|
<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); setEditError(null); setShowCancelConfirm(false); setActiveRegId(null); }}>Close</button>
|
|
</div>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<div className="text-sm text-gray-600">Status: {activeReg.status}</div>
|
|
{activeBill && (
|
|
<div className="text-sm">
|
|
<div>Total: {formatRand(activeBill.totalDue)}</div>
|
|
<div>Paid: {formatRand(activeBill.totalPaid)}</div>
|
|
<div>Outstanding: <span className={activeBill.outstanding > 0 ? "text-red-600" : "text-green-700"}>{formatRand(activeBill.outstanding)}</span></div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<div className="font-medium mb-1 flex items-center justify-between">
|
|
<span>Registration items</span>
|
|
{!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>
|
|
{!editMode ? (
|
|
<ul className="text-sm list-disc pl-5 space-y-1">
|
|
{(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 (
|
|
<li key={opt.id}>
|
|
{opt.eventOption?.name}{variantLabel} x {opt.quantity} — {formatRand(unitPrice * (opt.quantity || 0))}
|
|
{unitPrice < (opt.eventOption?.price || 0) && (
|
|
<span className="ml-1 text-xs text-green-700">(early bird)</span>
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
</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>
|
|
) : (
|
|
<>
|
|
<div className="space-y-2">
|
|
{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 (
|
|
<div className="flex items-center gap-2">
|
|
<label className="text-xs text-gray-600">Qty</label>
|
|
<input type="number" min={minQty} max={maxQty} value={qty}
|
|
onChange={e => {
|
|
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" />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
if (hasVariants) {
|
|
const variants = eo.variants.slice().sort((a: any, b: any) => (a.order || 0) - (b.order || 0));
|
|
return (
|
|
<div key={eo.id} className="border rounded bg-white overflow-hidden">
|
|
<div className="px-3 py-2 bg-gray-100 text-sm font-medium">{eo.name}</div>
|
|
<ul className="divide-y">
|
|
{variants.map((v: any) => {
|
|
const key = `${eo.id}:${v.id}`;
|
|
const minQty = editMinQty[key] || 0;
|
|
const unitPrice = effectiveEventOptionUnitPrice(eo, v.id);
|
|
return (
|
|
<li key={v.id} className="flex items-center justify-between gap-2 px-3 py-2">
|
|
<div className="text-sm">
|
|
<div>{v.name}</div>
|
|
<div className="text-gray-600 text-xs">{formatRand(unitPrice)}</div>
|
|
{minQty > 0 && (
|
|
<div className="text-gray-500 text-[11px]">{minQty} already issued</div>
|
|
)}
|
|
</div>
|
|
{renderQtyInput(key, unitPrice, v.stockLimit, v.availableCount)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const key = eo.id;
|
|
const minQty = editMinQty[key] || 0;
|
|
const unitPrice = effectiveEventOptionUnitPrice(eo, null);
|
|
return (
|
|
<div key={eo.id} className="flex items-center justify-between gap-2 border rounded bg-white px-3 py-2">
|
|
<div className="text-sm">
|
|
<div className="font-medium">{eo.name}</div>
|
|
<div className="text-gray-600 text-xs">{formatRand(unitPrice)}</div>
|
|
{minQty > 0 && (
|
|
<div className="text-gray-500 text-[11px]">{minQty} already issued</div>
|
|
)}
|
|
</div>
|
|
{renderQtyInput(key, unitPrice, eo.stockLimit, eo.availableCount)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="flex items-center justify-between pt-2">
|
|
<div className="text-sm">
|
|
{(() => {
|
|
const paid = activeBill?.totalPaid || 0;
|
|
const hasAnyQty = Object.values(editQuantities).some(q => q > 0);
|
|
const invalid = editTotal < paid || !hasAnyQty;
|
|
return (
|
|
<>
|
|
<div>New total: {formatRand(editTotal)}</div>
|
|
{paid > 0 && <div className="text-xs text-gray-600">Paid: {formatRand(paid)}</div>}
|
|
{invalid && <div className="text-xs text-red-600">Total cannot be below amount already paid and at least one item is required.</div>}
|
|
</>
|
|
);
|
|
})()}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<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>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{activeBill && activeBill.payments.length > 0 && (
|
|
<div>
|
|
<div className="font-medium mb-1">Payments</div>
|
|
<ul className="text-sm list-disc pl-5 space-y-1">
|
|
{activeBill.payments.map((p: any) => (
|
|
<li key={p.id}>{new Date(p.createdAt).toLocaleString()} — {formatRand(p.amount)} ({p.method || "Payment"})</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
<div className="flex flex-wrap gap-2 pt-2">
|
|
{showFormsButton && (
|
|
<button
|
|
className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1"
|
|
onClick={() => router.push(`/dashboard/user/forms?registrationId=${encodeURIComponent(activeReg.id)}`)}
|
|
>Attendee forms</button>
|
|
)}
|
|
{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)}`)}
|
|
>Make payment</button>
|
|
) : (
|
|
<>
|
|
<button className="px-3 py-1.5 text-sm 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={() => {
|
|
const regOptIds = new Set((activeReg.registrationOptions || []).map((o: any) => o.id));
|
|
const list = tickets.filter(t => regOptIds.has(t.registrationOptionId));
|
|
printTickets(list);
|
|
}}>Print all tickets</button>
|
|
<button className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => emailRegistration(activeReg.id)}>Email all tickets</button>
|
|
{user?.phoneNumber && (
|
|
<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={() => whatsappRegistration(activeReg.id)}>WhatsApp tickets</button>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* 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
|
|
className="text-sm text-red-600 hover:text-red-800 underline underline-offset-2"
|
|
onClick={() => setShowCancelConfirm(true)}
|
|
>
|
|
Cancel registration
|
|
</button>
|
|
) : (
|
|
<div className="flex items-center gap-2 p-2 rounded border border-red-200 bg-red-50">
|
|
<span className="text-sm text-red-700 flex-1">Are you sure you want to cancel? This cannot be undone.</span>
|
|
<button
|
|
disabled={cancelLoading}
|
|
className="px-2.5 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50"
|
|
onClick={cancelRegistration}
|
|
>{cancelLoading ? 'Cancelling…' : 'Yes, cancel'}</button>
|
|
<button
|
|
className="px-2.5 py-1 text-xs rounded bg-gray-200 hover:bg-gray-300"
|
|
onClick={() => setShowCancelConfirm(false)}
|
|
>No</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{dialog.open && (
|
|
<div
|
|
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
|
|
onClick={() => { if (!dialog.loading) setDialog({ open: false, message: "", loading: false }); }}
|
|
>
|
|
<div className="bg-white rounded-lg shadow-lg max-w-sm w-full p-5" onClick={e => e.stopPropagation()}>
|
|
{dialog.loading ? (
|
|
<div className="flex flex-col items-center">
|
|
<div className="w-10 h-10 mb-3 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" aria-label="Loading" />
|
|
<div className="text-base font-medium">Sending tickets…</div>
|
|
<p className="text-sm text-gray-600 mt-1">Please wait while we send your tickets.</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="text-lg font-semibold mb-2">Tickets sent</div>
|
|
<p className="text-sm text-gray-700 mb-4">{dialog.message}</p>
|
|
<div className="flex justify-end">
|
|
<button
|
|
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
|
|
onClick={() => setDialog({ open: false, message: "", loading: false })}
|
|
>OK</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|