Type payments and user dashboard pages

Moves the duplicated registration-option pricing helper into
src/lib/pricing.ts and drops an unused users list from payments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 11:10:01 +02:00
co-authored by Claude Opus 5.5
parent 0a8788afff
commit f6581eb441
4 changed files with 216 additions and 227 deletions
@@ -1,6 +1,6 @@
"use client";
import React, { Suspense, useEffect, useMemo, useRef, useState } from "react";
import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers, fetchAllPayments } from "@/lib/api";
@@ -8,13 +8,15 @@ import { useDismissingState } from "@/hooks/useDismissingState";
import { scoreUser } from "@/lib/fuzzyMatch";
import { Wallet, RotateCcw, HandHeart, ScanLine, Link2 } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { registrationOptionUnitPrice } from "@/lib/pricing";
import type { Event, Payment, Registration, User } from "@/types";
// A donation is never mutated once created — assigning it to a registration creates a separate
// "leg" Payment row (isDonation:false, originalPaymentId -> the donation, amount > 0) instead.
// That leg is not new money: it just re-labels part of an already-counted donation as applied
// to a registration. Money stats/lists must count each real inflow exactly once, so legs are
// excluded — the money was already counted via the original donation row.
function isDonationLeg(p: any): boolean {
function isDonationLeg(p: Payment): boolean {
return !p?.isDonation && !!p?.originalPaymentId && (p?.amount || 0) > 0;
}
@@ -30,13 +32,27 @@ function refundMethodForOriginal(method: string | null | undefined): string {
return "";
}
type Outstanding = Record<string, { totalDue: number; totalPaid: number; outstanding: number }>;
// An unreconciled Yoco transaction (GET /api/yoco-transactions/unreconciled). `raw` is the
// webhook as Yoco sent it; its checkout metadata says who/what the payment was for.
type YocoTx = {
id: string;
amount: number;
checkoutId?: string | null;
externalId?: string | null;
methodType?: string | null;
createdDate?: string | null;
raw?: { payload?: { metadata?: { userId?: string; eventId?: string } } } | null;
};
function RegistrationOptions({ regs, regOutstanding }: {
regs: any[];
regs: Registration[];
regOutstanding: Record<string, { totalDue: number; totalPaid: number; outstanding: number }>;
}) {
return (
<>
{regs.map((r: any) => {
{regs.map((r) => {
const out = regOutstanding[r.id]?.outstanding ?? 0;
const label = `${r.event?.title || r.eventId || 'Event'} — ${r.user?.name || r.userId || 'User'} — Outstanding: R ${out.toFixed(2)} — #${String(r.id).slice(0,8)}`;
return (
@@ -50,7 +66,7 @@ function RegistrationOptions({ regs, regOutstanding }: {
}
function UserSearchField({ allUsers, value, onChange, placeholder = "Search by name, email or phone…", disabled = false }: {
allUsers: any[];
allUsers: User[];
value: string;
onChange: (userId: string) => void;
placeholder?: string;
@@ -137,73 +153,45 @@ function PaymentsContent() {
if (!user) router.replace("/login");
}, [user, loading, router]);
const [payments, setPayments] = useState<any[]>([]);
const [payments, setPayments] = useState<Payment[]>([]);
const [loadingList, setLoadingList] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const [registrations, setRegistrations] = useState<any[]>([]);
const [registrations, setRegistrations] = useState<Registration[]>([]);
const [loadingRegs, setLoadingRegs] = useState(false);
const [regOutstanding, setRegOutstanding] = useState<Record<string, { totalDue: number; totalPaid: number; outstanding: number }>>({});
const [regOutstanding, setRegOutstanding] = useState<Outstanding>({});
const loadPayments = async () => {
const loadPayments = useCallback(async () => {
if (!token) return;
try {
setLoadingList(true);
const list = await fetchAllPayments(token);
setPayments(list.sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()));
setPayments(list.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()));
} catch (e) {
setError(errorMessage(e) || "Failed to load payments");
} finally {
setLoadingList(false);
}
};
}, [token, setError]);
useEffect(() => { loadPayments(); }, [token]);
// 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;
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() : [];
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(() => { loadPayments(); }, [loadPayments]);
// Load registrations for dropdowns.
// GET /api/registrations already embeds each registration's `payments`, so outstanding
// balances are computed from that in one pass — no per-registration follow-up requests.
const loadRegistrations = async () => {
const loadRegistrations = useCallback(async () => {
if (!token) return;
try {
setLoadingRegs(true);
const regs = await apiFetch<any[]>("/api/registrations", { authToken: token });
const regs = await apiFetch<Registration[]>("/api/registrations", { authToken: token });
const list = Array.isArray(regs) ? regs : [];
const now = new Date();
const map: Record<string, { totalDue: number; totalPaid: number; outstanding: number }> = {};
const map: Outstanding = {};
for (const r of list) {
// Backend attaches a tranche-aware totalDueComputed (exact even when a line spans
// multiple early-bird prices) — fall back to the old client-side estimate otherwise.
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
const totalPaid = (r.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0);
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => sum + registrationOptionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
const totalPaid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
map[r.id] = { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid) };
}
setRegOutstanding(map);
@@ -215,9 +203,9 @@ function PaymentsContent() {
} finally {
setLoadingRegs(false);
}
};
}, [token]);
useEffect(() => { loadRegistrations(); }, [token]);
useEffect(() => { loadRegistrations(); }, [loadRegistrations]);
// Load all users for Refund section
useEffect(() => {
@@ -241,12 +229,12 @@ function PaymentsContent() {
const [registrationId, setRegistrationId] = useState<string>("");
const [isDonation, setIsDonation] = useState<boolean>(false);
const [eventId, setEventId] = useState<string>("");
const [events, setEvents] = useState<any[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [submitting, setSubmitting] = useState(false);
// Backdate field
const [paidAtLocal, setPaidAtLocal] = useState<string>("");
// Users for Refund section (load all users with supervisor rights)
const [allUsers, setAllUsers] = useState<any[]>([]);
const [allUsers, setAllUsers] = useState<User[]>([]);
const [loadingUsers, setLoadingUsers] = useState(false);
// New: user selection states for split dropdowns
@@ -254,33 +242,12 @@ function PaymentsContent() {
// Derived lists for users and outstanding registrations
const outstandingRegs = useMemo(() => {
return registrations.filter((r: any) => (r.status !== 'paid') && (r.status !== 'cancelled') && ((regOutstanding[r.id]?.outstanding ?? 0) > 0));
return registrations.filter((r) => (r.status !== 'paid') && (r.status !== 'cancelled') && ((regOutstanding[r.id]?.outstanding ?? 0) > 0));
}, [registrations, regOutstanding]);
// Users list for UI dropdowns (use all users if loaded; fallback to outstanding registrations)
const usersList = useMemo(() => {
if (allUsers && allUsers.length > 0) {
return [...allUsers]
//.filter(u => u?.isActive !== false)
.map(u => ({ id: String(u.id), name: String(u.name || u.email || u.id) }))
.sort((a, b) => a.name.localeCompare(b.name, undefined as any, { sensitivity: 'base' } as any));
}
const map = new Map<string, string>();
for (const r of outstandingRegs) {
const uid = r.user?.id || r.userId;
if (!uid) continue;
const name = r.user?.name || r.user?.email || String(r.userId || uid);
if (!map.has(String(uid))) {
map.set(String(uid), String(name));
}
}
return Array.from(map.entries())
.map(([id, name]) => ({ id, name }))
.sort((a, b) => a.name.localeCompare(b.name, undefined as any, { sensitivity: 'base' } as any));
}, [allUsers, outstandingRegs]);
const regsForSelectedUser = useMemo(() => {
return outstandingRegs.filter((r: any) => (String(r.user?.id || r.userId) === String(selectedUserId)));
return outstandingRegs.filter((r) => (String(r.user?.id || r.userId) === String(selectedUserId)));
}, [outstandingRegs, selectedUserId]);
// Payment link tab state
@@ -293,7 +260,7 @@ function PaymentsContent() {
const [linkCopied, setLinkCopied] = useState(false);
const regsForLinkUser = useMemo(() => {
return outstandingRegs.filter((r: any) => (String(r.user?.id || r.userId) === String(linkUserId)));
return outstandingRegs.filter((r) => (String(r.user?.id || r.userId) === String(linkUserId)));
}, [outstandingRegs, linkUserId]);
useEffect(() => {
@@ -372,7 +339,7 @@ function PaymentsContent() {
// If a registration is preselected via query, infer and set its user
useEffect(() => {
if (!registrationId) return;
const reg = registrations.find((r: any) => String(r.id) === String(registrationId));
const reg = registrations.find((r) => String(r.id) === String(registrationId));
const uid = reg?.user?.id || reg?.userId;
if (uid) setSelectedUserId(String(uid));
}, [registrationId, registrations]);
@@ -388,7 +355,7 @@ function PaymentsContent() {
if (eventsIncludePast) params.set("includePast", "true");
if (eventsIncludeInactive) params.set("includeInactive", "true");
const qs = params.toString() ? `?${params.toString()}` : "";
const evs = await apiFetch<any[]>(`/api/events/all${qs}`, { authToken: token });
const evs = await apiFetch<Event[]>(`/api/events/all${qs}`, { authToken: token });
setEvents(evs || []);
} catch {}
})();
@@ -455,38 +422,38 @@ function PaymentsContent() {
// Yoco unreconciled list and actions
const [yocoLoading, setYocoLoading] = useState(false);
const [yocoTxs, setYocoTxs] = useState<any[]>([]);
const loadYocoUnreconciled = async () => {
const [yocoTxs, setYocoTxs] = useState<YocoTx[]>([]);
const loadYocoUnreconciled = useCallback(async () => {
if (!token) return;
try {
setYocoLoading(true);
const res = await apiFetch<any>("/api/yoco-transactions/unreconciled", { authToken: token });
const items = Array.isArray(res?.data) ? res.data : (Array.isArray(res) ? res : []);
const res = await apiFetch<{ data?: YocoTx[] } | YocoTx[]>("/api/yoco-transactions/unreconciled", { authToken: token });
const items = Array.isArray(res) ? res : (Array.isArray(res?.data) ? res.data : []);
setYocoTxs(items);
} catch {
// ignore silently, shown on demand via error state if needed
} finally {
setYocoLoading(false);
}
};
useEffect(() => { loadYocoUnreconciled(); }, [token]);
}, [token]);
useEffect(() => { loadYocoUnreconciled(); }, [loadYocoUnreconciled]);
// Inline reconcile form state per-row
const [reconcileForm, setReconcileForm] = useState<{ txId: string | null; type: 'reg' | 'don' | null; userId: string; registrationId: string; eventId: string; submitting: boolean }>({ txId: null, type: null, userId: '', registrationId: '', eventId: '', submitting: false });
const openReconcileToRegistration = (tx: any) => {
const openReconcileToRegistration = (tx: YocoTx) => {
const metaUser = tx?.raw?.payload?.metadata?.userId ? String(tx.raw.payload.metadata.userId) : '';
setReconcileForm({ txId: tx.id, type: 'reg', userId: metaUser, registrationId: '', eventId: '', submitting: false });
};
const openReconcileAsDonation = (tx: any) => {
const openReconcileAsDonation = (tx: YocoTx) => {
const metaEvent = tx?.raw?.payload?.metadata?.eventId ? String(tx.raw.payload.metadata.eventId) : '';
setReconcileForm({ txId: tx.id, type: 'don', userId: '', registrationId: '', eventId: metaEvent, submitting: false });
};
const regsForUser = (uid: string) => registrations
.filter((r: any) => String(r.user?.id || r.userId) === String(uid))
.filter((r: any) => {
.filter((r) => String(r.user?.id || r.userId) === String(uid))
.filter((r) => {
const out = regOutstanding[r.id]?.outstanding ?? 0;
return out > 0.000001; // show only registrations with an outstanding balance
});
@@ -638,7 +605,7 @@ function PaymentsContent() {
<label className="block text-xs text-gray-600 mb-1">Registration</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={reconcileForm.registrationId} onChange={e => setReconcileForm(prev => ({ ...prev, registrationId: e.target.value }))} disabled={!reconcileForm.userId}>
<option value="">Select registration…</option>
{reconcileForm.userId && regsForUser(reconcileForm.userId).map((r: any) => {
{reconcileForm.userId && regsForUser(reconcileForm.userId).map((r) => {
const out = regOutstanding[r.id]?.outstanding ?? 0;
const label = `${r.event?.title || r.eventId || 'Event'} — Outstanding: R ${out.toFixed(2)} — #${String(r.id).slice(0,8)}`;
return <option key={r.id} value={r.id} title={label}>{label}</option>;
@@ -754,7 +721,7 @@ function PaymentsContent() {
<RefundSection
payments={payments}
allUsers={allUsers}
regsForUser={(uid:string)=> registrations.filter((r:any)=> String(r.user?.id||r.userId)===String(uid))}
regsForUser={(uid:string)=> registrations.filter((r)=> String(r.user?.id||r.userId)===String(uid))}
regOutstanding={regOutstanding}
onDone={async()=>{ await Promise.all([loadPayments(), loadRegistrations()]); setInfo('Refund recorded'); }}
/>
@@ -875,10 +842,10 @@ function PaymentsContent() {
}
type RefundSectionProps = {
payments: any[];
allUsers: any[];
regsForUser: (userId: string) => any[];
regOutstanding: Record<string, { totalDue: number; totalPaid: number; outstanding: number }>;
payments: Payment[];
allUsers: User[];
regsForUser: (userId: string) => Registration[];
regOutstanding: Outstanding;
onDone: () => void | Promise<void>;
};
@@ -895,7 +862,7 @@ function RefundSection({ payments, allUsers, regsForUser, regOutstanding, onDone
const [err, setErr] = useState<string | null>(null);
const paymentsForUser = useMemo(() => {
if (!userId) return [] as any[];
if (!userId) return [];
return payments.filter(p => String(p.userId) === String(userId) && (p.amount || 0) > 0);
}, [payments, userId]);
@@ -969,7 +936,7 @@ function RefundSection({ payments, allUsers, regsForUser, regOutstanding, onDone
<label className="text-xs text-gray-600 mt-2">Payment</label>
<select className="w-full max-w-full border rounded px-3 py-2 text-sm truncate" value={paymentId} onChange={e => setPaymentId(e.target.value)} disabled={!userId}>
<option value="">Select payment…</option>
{paymentsForUser.map((p:any)=>{
{paymentsForUser.map((p)=>{
const label = `R ${(p.amount||0).toFixed(2)} — ${p.registrationId ? `Registration ${String(p.registrationId).slice(0,8)}` : (p.event?.title || p.eventId || 'Event')} — #${String(p.id).slice(0,8)}`;
return <option key={p.id} value={p.id} title={label}>{label}</option>;
})}
@@ -980,7 +947,7 @@ function RefundSection({ payments, allUsers, regsForUser, regOutstanding, onDone
<label className="text-xs text-gray-600 mt-2">Registration</label>
<select className="w-full max-w-full border rounded px-3 py-2 text-sm truncate" value={registrationId} onChange={e => setRegistrationId(e.target.value)} disabled={!userId}>
<option value="">Select registration…</option>
{regs.map((r:any)=>{
{regs.map((r)=>{
const out = regOutstanding[r.id]?.outstanding ?? 0;
const label = `${r.event?.title || r.eventId || 'Event'} — Outstanding: R ${out.toFixed(2)} — #${String(r.id).slice(0,8)}`;
return <option key={r.id} value={r.id} title={label}>{label}</option>;
@@ -1018,7 +985,7 @@ function RefundSection({ payments, allUsers, regsForUser, regOutstanding, onDone
// stats both deliberately filter them out (they're not new money, see isDonationLeg's comment) —
// so this is the only place staff can see and reverse an assignment.
type AssignedDonationsListProps = {
payments: any[];
payments: Payment[];
onDone: () => void | Promise<void>;
};
@@ -1032,12 +999,12 @@ function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps)
const allLegs = useMemo(() => {
return payments
.filter(isDonationLeg)
.sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
}, [payments]);
const donationById = useMemo(() => {
const m = new Map<string, any>();
payments.forEach((p: any) => { if (p.isDonation) m.set(p.id, p); });
const m = new Map<string, Payment>();
payments.forEach((p) => { if (p.isDonation) m.set(p.id, p); });
return m;
}, [payments]);
@@ -1045,7 +1012,7 @@ function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps)
// never shows an event with nothing to filter down to.
const eventOptions = useMemo(() => {
const m = new Map<string, string>();
allLegs.forEach((leg: any) => {
allLegs.forEach((leg) => {
const ev = leg.registration?.event;
if (ev?.id) m.set(String(ev.id), ev.title || String(ev.id));
});
@@ -1054,10 +1021,10 @@ function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps)
const legs = useMemo(() => {
const q = query.trim().toLowerCase();
return allLegs.filter((leg: any) => {
return allLegs.filter((leg) => {
if (eventFilter && String(leg.registration?.eventId || leg.registration?.event?.id) !== eventFilter) return false;
if (!q) return true;
const donation = donationById.get(leg.originalPaymentId);
const donation = leg.originalPaymentId ? donationById.get(leg.originalPaymentId) : undefined;
const haystack = [
leg.registration?.user?.name,
leg.registration?.user?.email,
@@ -1071,7 +1038,7 @@ function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps)
});
}, [allLegs, donationById, query, eventFilter]);
const unassign = async (leg: any) => {
const unassign = async (leg: Payment) => {
if (!token) return;
setErr(null);
const ok = window.confirm("Unassign this donation? The registration's balance will increase, and any tickets issued only because this payment completed it may be revoked.");
@@ -1119,8 +1086,8 @@ function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps)
<div className="text-sm text-gray-500">No assigned donations match your search.</div>
) : (
<ul className="text-sm space-y-2 max-h-[420px] overflow-auto pr-2">
{legs.map((leg: any) => {
const donation = donationById.get(leg.originalPaymentId);
{legs.map((leg) => {
const donation = leg.originalPaymentId ? donationById.get(leg.originalPaymentId) : undefined;
return (
<li key={leg.id} className="border rounded p-2">
<div className="flex justify-between items-start gap-2">
@@ -1152,10 +1119,10 @@ function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps)
}
type DonationAssignSectionProps = {
payments: any[];
allUsers: any[];
registrations: any[];
regOutstanding: Record<string, { totalDue: number; totalPaid: number; outstanding: number }>;
payments: Payment[];
allUsers: User[];
registrations: Registration[];
regOutstanding: Outstanding;
onDone: () => void | Promise<void>;
};
@@ -1179,8 +1146,8 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
// Registrations for the selected user that can actually receive a donation: an outstanding
// balance to apply it to, and an event that isn't closed.
const regsForUser = useMemo(() => {
if (!userId) return [] as any[];
return registrations.filter((r: any) => {
if (!userId) return [];
return registrations.filter((r) => {
if (String(r.user?.id || r.userId) !== String(userId)) return false;
if (r.status === 'cancelled') return false;
if (r.event?.cashupStatus === 'closed') return false;
@@ -1190,7 +1157,7 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
}, [registrations, regOutstanding, userId]);
const selectedRegistration = useMemo(
() => registrations.find((r: any) => String(r.id) === String(registrationId)),
() => registrations.find((r) => String(r.id) === String(registrationId)),
[registrations, registrationId]
);
@@ -1203,7 +1170,7 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
// adding the refund back on top of what's left to allocate).
const legsById = useMemo(() => {
const m = new Map<string, number>();
payments.forEach((p: any) => {
payments.forEach((p) => {
if (p.originalPaymentId && !p.isDonation) {
m.set(p.originalPaymentId, (m.get(p.originalPaymentId) || 0) + Math.abs(p.amount || 0));
}
@@ -1214,9 +1181,9 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
// Donations "relevant to that event" — donations logged against the same event as the
// chosen registration that still have a remaining, unused balance.
const donationsForEvent = useMemo(() => {
if (!selectedRegistration) return [] as any[];
if (!selectedRegistration) return [];
const eventId = selectedRegistration.eventId || selectedRegistration.event?.id;
return payments.filter((p: any) => {
return payments.filter((p) => {
if (!p.isDonation || p.registrationId) return false;
if (String(p.eventId) !== String(eventId)) return false;
if (sponsorshipsOnly && !p.isSponsorship) return false;
@@ -1226,7 +1193,7 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
}, [payments, selectedRegistration, legsById, sponsorshipsOnly]);
const selectedDonation = useMemo(
() => payments.find((p: any) => String(p.id) === String(paymentId)),
() => payments.find((p) => String(p.id) === String(paymentId)),
[payments, paymentId]
);
@@ -1305,7 +1272,7 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
disabled={!registrationId}
>
<option value="">Select donation…</option>
{donationsForEvent.map((p: any) => {
{donationsForEvent.map((p) => {
const remaining = (p.amount || 0) - (legsById.get(p.id) || 0);
const donor = p.isSponsorship ? `Sponsorship: ${p.sponsorAnonymous ? 'anonymous' : (p.sponsorName || p.user?.name || 'sponsor')}` : (p.user?.name || p.userId || 'Donor');
const label = `R ${remaining.toFixed(2)} of R ${(p.amount || 0).toFixed(2)} left — ${donor} — #${String(p.id).slice(0,8)}`;
+86 -110
View File
@@ -13,16 +13,21 @@ import { MyWaitlists } from "@/components/shared/MyWaitlists";
import { TransferTicketModal, canOfferTransfer } from "@/components/shared/TransferTicketModal";
import { ClipboardList, Calendar, Ticket, ChevronRight } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { registrationOptionUnitPrice } from "@/lib/pricing";
import type { Event, EventOption, Payment, Registration, RegistrationOption, Ticket as TicketRecord } from "@/types";
// 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) =>
type EventStatusFields = Pick<Event, "endDate" | "cashupStatus" | "isActive">;
type Billing = { totalDue: number; totalPaid: number; outstanding: number; payments: Payment[] };
const isEventOver = (ev: EventStatusFields | null | undefined) =>
!!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 }) {
function EventStatusBadge({ event }: { event: EventStatusFields | null | undefined }) {
if (!event) return null;
let label: string | null = null;
let className = '';
@@ -54,26 +59,26 @@ function RegistrationStatusBadge({ status }: { status: string }) {
// 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;
function effectiveEventOptionUnitPrice(eo: EventOption, variantId: string | null): number {
const variant = variantId ? (eo?.variants || []).find((v) => 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);
const tiers = variantId ? allTiers.filter((t) => t.variantId === variantId) : allTiers.filter((t) => !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());
.map((t) => ({ ...t, _d: new Date(t.deadline) }))
.filter((t) => now < t._d)
.sort((a, b) => 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 [registrations, setRegistrations] = useState<Registration[]>([]);
const [tickets, setTickets] = useState<TicketRecord[]>([]);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const [loading, setLoading] = useState<boolean>(false);
@@ -82,7 +87,7 @@ export default function UserDashboardPage() {
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[] }>>({});
const [billing, setBilling] = useState<Record<string, Billing>>({});
// Forms status cache for registrations
// regId -> { hasForm: boolean; isRequired?: boolean; submittedCount?: number; requiredCount?: number; complete?: boolean }
@@ -100,35 +105,6 @@ export default function UserDashboardPage() {
// 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 () => {
@@ -140,29 +116,29 @@ export default function UserDashboardPage() {
// 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 }),
apiFetch<Registration[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token }),
apiFetch<TicketRecord[]>("/api/tickets/mytickets", { authToken: token }),
]);
setRegistrations(myRegs);
setTickets(myTicks);
// Use the backend's tranche-aware totalDueComputed (exact even when a line spans
// multiple early-bird prices) rather than re-deriving from priceSnapshot client-side.
const totals: Record<string, { totalDue: number; totalPaid: number; outstanding: number; payments: any[] }> = {};
const totals: Record<string, Billing> = {};
for (const r of myRegs) {
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => sum + registrationOptionUnitPrice(opt, null, new Date()) * (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) => {
myRegs.map(async (r) => {
try {
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(r.id)}`, { authToken: token });
const pays = await apiFetch<Payment[]>(`/api/payments/registration/${encodeURIComponent(r.id)}`, { authToken: token });
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
// totalDueComputed is not time-dependent, no need to recompute per payment time
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => sum + registrationOptionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
const outstanding = Math.max(0, totalDue - totalPaid);
setBilling(prev => ({
...prev,
@@ -185,7 +161,7 @@ export default function UserDashboardPage() {
setLoading(false);
}
})();
}, [token, showPast]);
}, [token, showPast, setError]);
// After registrations load, compute forms status for each registration.
// `myregistrations` now embeds event.form (with fields) directly, so this no longer
@@ -196,7 +172,7 @@ export default function UserDashboardPage() {
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) => {
registrations.map(async (r) => {
try {
const ev = r.event;
const hasForm = !!(ev?.form && Array.isArray(ev.form.fields) && ev.form.fields.length > 0);
@@ -205,12 +181,12 @@ export default function UserDashboardPage() {
return;
}
// Load registration to get number of submitted responses
const regFull = await apiFetch<any>(`/api/registrations/${encodeURIComponent(r.id)}`, { authToken: token });
const regFull = await apiFetch<Registration>(`/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);
.filter((ro) => ro.eventOption?.isMainTicket)
.reduce((s, ro) => s + (ro.quantity || 0), 0);
const complete = submittedCount >= requiredCount && requiredCount > 0 ? true : (submittedCount >= requiredCount);
next[r.id] = {
hasForm: true,
@@ -246,7 +222,7 @@ export default function UserDashboardPage() {
// Open loading dialog immediately
setDialog({ open: true, message: "Sending tickets…", loading: true });
try {
const res: any = await apiFetch("/api/tickets/email", {
const res = await apiFetch<{ message?: string }>("/api/tickets/email", {
method: "POST",
body: { ticketIds },
authToken: token,
@@ -266,7 +242,7 @@ export default function UserDashboardPage() {
setInfo(null);
setDialog({ open: true, message: "Sending to WhatsApp…", loading: true });
try {
const res: any = await apiFetch("/api/tickets/email", {
const res = await apiFetch<{ message?: string }>("/api/tickets/email", {
method: "POST",
body: { ticketIds, channel: "whatsapp" },
authToken: token,
@@ -289,7 +265,7 @@ export default function UserDashboardPage() {
loadingSubtitle: "Please wait while we prepare your invoice.",
});
try {
const res: any = await apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/send-invoice`, {
const res = await apiFetch<{ message?: string }>(`/api/registrations/${encodeURIComponent(registrationId)}/send-invoice`, {
method: "POST",
body: { channel },
authToken: token,
@@ -311,7 +287,7 @@ export default function UserDashboardPage() {
loadingSubtitle: "Please wait while we prepare your receipt.",
});
try {
const res: any = await apiFetch(`/api/payments/${encodeURIComponent(paymentId)}/send-receipt`, {
const res = await apiFetch<{ message?: string }>(`/api/payments/${encodeURIComponent(paymentId)}/send-receipt`, {
method: "POST",
body: { channel },
authToken: token,
@@ -348,10 +324,10 @@ export default function UserDashboardPage() {
// Refresh this registration's billing so the displayed outstanding reflects the new price
try {
const now = new Date();
const r = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(registrationId)}`, { authToken: token });
const r = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
const pays = await apiFetch<Payment[]>(`/api/payments/registration/${encodeURIComponent(registrationId)}`, { authToken: token });
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
const totalDue = (r.registrationOptions || []).reduce((sum, opt) => sum + registrationOptionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
setBilling(prev => ({ ...prev, [registrationId]: { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid), payments: pays } }));
} catch {}
return;
@@ -369,7 +345,7 @@ export default function UserDashboardPage() {
};
// Print helpers
const buildTicketHtmlCard = (t: any, buyerName?: string) => {
const buildTicketHtmlCard = (t: TicketRecord, 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";
@@ -440,7 +416,7 @@ export default function UserDashboardPage() {
w.focus();
};
const printTickets = (ticketList: any[]) => {
const printTickets = (ticketList: TicketRecord[]) => {
if (!ticketList || ticketList.length === 0) return;
const cards = ticketList.map(t => buildTicketHtmlCard(t, user?.name)).join("");
openPrintWindow(`Tickets (${ticketList.length})`, cards);
@@ -448,7 +424,7 @@ export default function UserDashboardPage() {
// Upcoming events derived from registrations
const upcomingEvents = useMemo(() => {
const map = new Map<string, any>();
const map = new Map<string, Event>();
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);
@@ -475,7 +451,7 @@ export default function UserDashboardPage() {
}
(async () => {
try {
const ev = await apiFetch<any>(`/api/events/${encodeURIComponent(ar.eventId)}`);
const ev = await apiFetch<Event>(`/api/events/${encodeURIComponent(ar.eventId)}`);
const has = !!(ev?.form && Array.isArray(ev.form.fields) && ev.form.fields.length > 0);
setActiveEventHasForm(has);
} catch {
@@ -506,15 +482,15 @@ export default function UserDashboardPage() {
authToken: token,
body: { code: codeInput.trim() },
});
const myRegs = await apiFetch<any[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
const myRegs = await apiFetch<Registration[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
setRegistrations(myRegs);
const updated = myRegs.find(r => r.id === activeRegId);
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(activeRegId)}`, { authToken: token });
const pays = await apiFetch<Payment[]>(`/api/payments/registration/${encodeURIComponent(activeRegId)}`, { authToken: token });
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
const totalDue = updated?.totalDueComputed ?? 0;
setBilling(prev => ({ ...prev, [activeRegId]: { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid), payments: pays } }));
try {
const myTicks = await apiFetch<any[]>("/api/tickets/mytickets", { authToken: token });
const myTicks = await apiFetch<TicketRecord[]>("/api/tickets/mytickets", { authToken: token });
setTickets(myTicks);
} catch {}
setCodeInput("");
@@ -526,15 +502,15 @@ export default function UserDashboardPage() {
}
};
const [transferTarget, setTransferTarget] = useState<any | null>(null);
const [transferTarget, setTransferTarget] = useState<TicketRecord | null>(null);
// Re-fetch registrations and tickets after something on one changes (attendee names).
const reloadAfterAttendees = async () => {
if (!token) return;
try {
const myRegs = await apiFetch<any[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
const myRegs = await apiFetch<Registration[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
setRegistrations(myRegs);
const myTicks = await apiFetch<any[]>("/api/tickets/mytickets", { authToken: token });
const myTicks = await apiFetch<TicketRecord[]>("/api/tickets/mytickets", { authToken: token });
setTickets(myTicks);
} catch {}
};
@@ -551,7 +527,7 @@ export default function UserDashboardPage() {
method: 'DELETE',
authToken: token,
});
const myRegs = await apiFetch<any[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
const myRegs = await apiFetch<Registration[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
setRegistrations(myRegs);
setActiveRegId(null);
setShowCancelConfirm(false);
@@ -568,7 +544,7 @@ export default function UserDashboardPage() {
// "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 [editEventOptions, setEditEventOptions] = useState<EventOption[]>([]);
const [editQuantities, setEditQuantities] = useState<Record<string, number>>({});
const [editBaseQty, setEditBaseQty] = useState<Record<string, number>>({});
const [editMinQty, setEditMinQty] = useState<Record<string, number>>({});
@@ -582,9 +558,9 @@ export default function UserDashboardPage() {
}, [activeReg]);
const editTotal = useMemo(() => {
return editEventOptions.reduce((sum: number, eo: any) => {
return editEventOptions.reduce((sum, eo) => {
if (Array.isArray(eo.variants) && eo.variants.length > 0) {
return sum + eo.variants.reduce((vsum: number, v: any) => {
return sum + eo.variants.reduce((vsum, v) => {
const qty = editQuantities[`${eo.id}:${v.id}`] || 0;
return vsum + qty * effectiveEventOptionUnitPrice(eo, v.id);
}, 0);
@@ -599,10 +575,10 @@ export default function UserDashboardPage() {
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 eventData = await apiFetch<Event>(`/api/events/${encodeURIComponent(activeReg.eventId)}`);
const eventOptions = eventData?.eventOptions || [];
const regOptById = new Map<string, any>((activeReg.registrationOptions || []).map((o: any) => [o.id, o]));
const regOptById = new Map<string, RegistrationOption>((activeReg.registrationOptions || []).map((o) => [o.id, o]));
// Prefill quantities from the current registration
const qtyMap: Record<string, number> = {};
@@ -615,16 +591,16 @@ export default function UserDashboardPage() {
// (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);
const ro = t.registrationOptionId ? regOptById.get(t.registrationOptionId) : undefined;
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) => {
eventOptions.forEach((eo) => {
if (Array.isArray(eo.variants) && eo.variants.length > 0) {
eo.variants.forEach((v: any) => {
eo.variants.forEach((v) => {
const key = `${eo.id}:${v.id}`;
if (!(key in qtyMap)) qtyMap[key] = 0;
});
@@ -639,7 +615,7 @@ export default function UserDashboardPage() {
setEditMinQty(minQtyMap);
setEditMode(true);
} catch (e) {
setEditError((e as any)?.message || 'Failed to load event options');
setEditError(errorMessage(e) || 'Failed to load event options');
} finally {
setEditLoading(false);
}
@@ -653,8 +629,8 @@ export default function UserDashboardPage() {
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 eo = editEventOptions.find((o) => o.id === eventOptionId);
const variant = variantId ? (eo?.variants || []).find((v) => 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;
@@ -689,22 +665,22 @@ export default function UserDashboardPage() {
body: { options: payloadOptions },
});
// Reload registrations to reflect changes
const myRegs = await apiFetch<any[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
const myRegs = await apiFetch<Registration[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
setRegistrations(myRegs);
try {
const myTicks = await apiFetch<any[]>("/api/tickets/mytickets", { authToken: token });
const myTicks = await apiFetch<TicketRecord[]>("/api/tickets/mytickets", { authToken: token });
setTickets(myTicks);
} catch {}
// Recompute billing totals using the backend's tranche-aware totalDueComputed
const totals: Record<string, { totalDue: number; totalPaid: number; outstanding: number; payments: any[] }> = {};
const totals: Record<string, Billing> = {};
for (const r of myRegs) {
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => sum + registrationOptionUnitPrice(opt, null, new Date()) * (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 pays = await apiFetch<Payment[]>(`/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 => ({
@@ -778,9 +754,9 @@ export default function UserDashboardPage() {
<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) => {
.filter((r) => r.status !== 'cancelled')
.filter((r) => showPast || !isEventOver(r.event))
.map((r) => {
const bill = billing[r.id];
const isCancelled = r.status === 'cancelled';
const outstanding = isCancelled ? 0 : (bill?.outstanding ?? 0);
@@ -820,18 +796,18 @@ export default function UserDashboardPage() {
{(() => {
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 allTiers = (r.registrationOptions || []).flatMap((ro) => 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());
.map((t) => ({ ...t, deadline: new Date(t.deadline) }))
.filter((t) => new Date() < t.deadline)
.sort((a, b) => 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 lastPaymentAt = pays.length > 0 ? new Date(Math.max(...pays.map((p) => 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 priceNow = (r.registrationOptions || []).reduce((s, ro) => s + registrationOptionUnitPrice(ro, lastPaymentAt, now) * (ro.quantity || 0), 0);
const afterPrice = (r.registrationOptions || []).reduce((s, ro) => s + registrationOptionUnitPrice(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 (
@@ -962,8 +938,8 @@ export default function UserDashboardPage() {
) : (
<ul className="grid sm:grid-cols-2 gap-3">
{tickets
.filter((t: any) => showPast || !isEventOver(t.event))
.map((t: any) => {
.filter((t) => showPast || !isEventOver(t.event))
.map((t) => {
const eventDate = t.event?.startDate ? new Date(t.event.startDate) : null;
const optionName = t.registrationOption?.eventOption?.name || "Ticket";
const variantName = t.registrationOption?.variant?.name;
@@ -988,7 +964,7 @@ export default function UserDashboardPage() {
</div>
{(t.attendee?.allocations || []).length > 0 && (
<div className="text-xs text-violet-800 bg-violet-50 rounded px-2 py-0.5 inline-block ml-1">
{[...t.attendee.allocations].sort((a: any, b: any) => (a.set?.order || 0) - (b.set?.order || 0)).map((a: any) => `${a.set.name}: ${a.group.name}`).join(" · ")}
{[...(t.attendee?.allocations || [])].sort((a, b) => (a.set?.order || 0) - (b.set?.order || 0)).map((a) => `${a.set.name}: ${a.group.name}`).join(" · ")}
</div>
)}
{t.userId && user?.id && t.userId !== user.id && t.user?.name && (
@@ -1004,10 +980,10 @@ export default function UserDashboardPage() {
<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>
{(t.transferRequests || [])[0] && (
<span className="w-full text-xs text-violet-800 bg-violet-50 rounded px-2 py-1 flex items-center justify-between gap-2">
<span>Waiting for {t.transferRequests[0].toName} to accept</span>
<span>Waiting for {t.transferRequests?.[0]?.toName} to accept</span>
<button className="underline" onClick={async () => {
try {
const r: any = await apiFetch(`/api/tickets/${t.id}/transfer`, { method: "DELETE", authToken: token! });
const r = await apiFetch<{ message?: string }>(`/api/tickets/${t.id}/transfer`, { method: "DELETE", authToken: token! });
setInfo(r?.message || "Transfer cancelled");
reloadAfterAttendees();
} catch (e) { setError(errorMessage(e) || "Couldn't cancel"); }
@@ -1071,7 +1047,7 @@ export default function UserDashboardPage() {
// early-bird rows separately from standard-price rows so repeat purchases
// of the same item don't show as a wall of near-duplicate lines.
const rows: { key: string; label: string; quantity: number; unitPrice: number; isEarlyBird: boolean }[] = [];
(activeReg.registrationOptions || []).forEach((opt: any) => {
(activeReg.registrationOptions || []).forEach((opt) => {
const variantLabel = opt.variant?.name ? ` (${opt.variant.name})` : '';
const label = `${opt.eventOption?.name || ''}${variantLabel}`;
const tranches = Array.isArray(opt.tranches) && opt.tranches.length > 0
@@ -1083,7 +1059,7 @@ export default function UserDashboardPage() {
: (opt.variant?.price ?? opt.eventOption?.price ?? 0),
appliedTierId: opt.appliedTierId,
}];
tranches.forEach((t: any) => {
tranches.forEach((t) => {
const unitPrice = Number(t.priceSnapshot || 0);
const isEarlyBird = !!t.appliedTierId;
const key = `${label}__${isEarlyBird}__${unitPrice}`;
@@ -1154,7 +1130,7 @@ export default function UserDashboardPage() {
) : (
<>
<div className="space-y-2">
{editEventOptions.map((eo: any) => {
{editEventOptions.map((eo) => {
const hasVariants = Array.isArray(eo.variants) && eo.variants.length > 0;
const renderQtyInput = (key: string, unitPrice: number, stockLimit?: number, availableCount?: number) => {
@@ -1179,12 +1155,12 @@ export default function UserDashboardPage() {
};
if (hasVariants) {
const variants = eo.variants.slice().sort((a: any, b: any) => (a.order || 0) - (b.order || 0));
const variants = (eo.variants || []).slice().sort((a, b) => (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) => {
{variants.map((v) => {
const key = `${eo.id}:${v.id}`;
const minQty = editMinQty[key] || 0;
const unitPrice = effectiveEventOptionUnitPrice(eo, v.id);
@@ -1262,7 +1238,7 @@ export default function UserDashboardPage() {
<div>
<div className="font-medium mb-1">Payments</div>
<ul className="text-sm space-y-1">
{activeBill.payments.map((p: any) => (
{activeBill.payments.map((p) => (
<li key={p.id} className="flex items-center justify-between gap-2 py-0.5">
<span>{new Date(p.createdAt).toLocaleString()} — {formatRand(p.amount)} ({formatPaymentMethod(p.method)})</span>
<button className="text-xs text-brand-700 hover:underline shrink-0" onClick={() => setChannelPicker({ kind: 'receipt', id: p.id })}>Receipt</button>
@@ -1289,8 +1265,8 @@ export default function UserDashboardPage() {
>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));
const regOptIds = new Set((activeReg.registrationOptions || []).map((o) => o.id));
const list = tickets.filter(t => !!t.registrationOptionId && regOptIds.has(t.registrationOptionId));
printTickets(list);
}}>Print all tickets</button>
</div>
+32
View File
@@ -0,0 +1,32 @@
import type { RegistrationOption } from "@/types";
// Effective unit price for a registration option. Uses priceSnapshot when available
// (authoritative backend price, variant-aware). Falls back to a deadline-only early-bird
// calculation for legacy rows without a snapshot: the cheapest tier still open both at
// `referenceTime` (e.g. the last payment) and at `atTime`.
export function registrationOptionUnitPrice(opt: RegistrationOption, referenceTime: string | Date | null, 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) => t.variantId === variantId)
: allTiers.filter((t) => !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) => ({ ...x, deadlineAt: new Date(x.deadline) }))
.filter((x) => (ref < x.deadlineAt) && (t < x.deadlineAt))
.sort((a, b) => a.deadlineAt.getTime() - b.deadlineAt.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;
}
+15 -1
View File
@@ -46,6 +46,8 @@ export type OptionVariant = {
staffPrice?: number | null;
stockLimit?: number;
order?: number;
// Public event endpoints add how many are left when a stock limit is set.
availableCount?: number;
};
export type EarlyBirdTier = {
@@ -65,6 +67,7 @@ export type EventOption = {
price: number;
isMainTicket?: boolean;
stockLimit?: number;
availableCount?: number;
staffPrice?: number | null;
variants?: OptionVariant[];
earlyBirdTiers?: EarlyBirdTier[];
@@ -300,6 +303,8 @@ export type Registration = {
payments?: Payment[];
formResponses?: FormResponse[];
attendees?: Attendee[];
// The discount code currently applied (released redemptions are left out).
couponRedemptions?: { id: string; discountAmount: number; createdAt?: string; coupon?: { id: string; code: string; type?: string; value?: number; description?: string | null } | null }[];
// Tranche-aware total the backend attaches to registration responses.
totalDueComputed?: number | null;
};
@@ -318,7 +323,13 @@ export type Ticket = {
createdAt: string;
updatedAt?: string;
user?: UserSummary;
attendee?: { id: string; name: string; email?: string | null; phone?: string | null } | null;
attendee?: {
id: string; name: string; email?: string | null; phone?: string | null;
// Room/group allocations shown on the ticket (only sets marked "show on ticket").
allocations?: { set: { name: string; order?: number }; group: { name: string } }[];
} | null;
// An open offer to transfer this ticket to someone else, awaiting their acceptance.
transferRequests?: { id: string; toName: string; toEmail?: string | null; toPhone?: string | null; createdAt?: string }[];
event?: Event;
registrationOption?: RegistrationOption;
usages?: { id: string; scannedAt: string; quantityRedeemed?: number }[];
@@ -334,6 +345,9 @@ export type Payment = {
eventId?: string | null;
isDonation?: boolean;
isSponsorship?: boolean;
sponsorName?: string | null;
sponsorMessage?: string | null;
sponsorAnonymous?: boolean;
status?: string | null;
externalId?: string | null;
originalPaymentId?: string | null;