Files
hope-events/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx
T
joshuaandClaude Sonnet 5 c07e9c928a Add door check-in flow for Main Tickets
Lets staff redeem a registration's Main Tickets by quantity at the door
(via the Payment/registration flow) instead of scanning each QR code,
and automatically emails/WhatsApps a check-in confirmation to the
attendee. Also routes the At The Door "Open" button and post-payment
flow dynamically: paid registrations jump straight to Check-In instead
of a forced ticket print, since tickets are already sent automatically.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:56:31 +02:00

1900 lines
81 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch";
import { useDismissingState } from "@/hooks/useDismissingState";
type Mode = "registration" | "payment" | "checkin" | "tickets" | "refund";
const MODE_LABELS: Record<Mode, string> = {
registration: "REGISTRATION",
payment: "PAYMENT",
checkin: "CHECK-IN",
tickets: "TICKETS",
refund: "REFUND",
};
// ─── Fuzzy search helpers ─────────────────────────────────────────────────────
function fuzzyFilterRegs(allRegs: any[], search: string): any[] {
if (!search) return allRegs;
if (search.length === 1) {
const q = search.toLowerCase();
return allRegs.filter(r =>
String(r.user?.name || "").toLowerCase().includes(q) ||
String(r.user?.email || "").toLowerCase().includes(q) ||
String(r.user?.phoneNumber || "").includes(q)
);
}
return allRegs
.map(r => ({ r, score: scoreUser(r.user || {}, search) }))
.filter(x => x.score >= 0.45)
.sort((a, b) => b.score - a.score)
.map(x => x.r);
}
// ─── Pricing helpers ─────────────────────────────────────────────────────────
function effectiveOptionUnit(opt: any): number {
const base = opt.price || 0;
const tiers = (Array.isArray(opt.earlyBirdTiers) ? opt.earlyBirdTiers : []).filter((t: any) => !t.variantId);
if (!tiers.length) return base;
const now = new Date();
const hit = tiers
.map((t: any) => ({ ...t, deadline: new Date(t.deadline) }))
.filter((t: any) => now < t.deadline)
.sort((a: any, b: any) => a.deadline - b.deadline || (a.order || 0) - (b.order || 0) || a.price - b.price);
return hit.length ? hit[0].price : base;
}
function effectiveVariantUnit(opt: any, variant: any): number {
const base = variant.price !== null && variant.price !== undefined ? variant.price : opt.price || 0;
const allTiers = Array.isArray(opt.earlyBirdTiers) ? opt.earlyBirdTiers : [];
const vtiers = allTiers.filter((t: any) => t.variantId === variant.id);
const tiers = vtiers.length ? vtiers : allTiers.filter((t: any) => !t.variantId);
if (!tiers.length) return base;
const now = new Date();
const hit = tiers
.map((t: any) => ({ ...t, deadline: new Date(t.deadline) }))
.filter((t: any) => now < t.deadline)
.sort((a: any, b: any) => a.deadline - b.deadline || (a.order || 0) - (b.order || 0) || a.price - b.price);
return hit.length ? hit[0].price : base;
}
function initQuantities(eventOptions: any[]): Record<string, number> {
const map: Record<string, number> = {};
(eventOptions || []).forEach((o: any) => {
if ((o.variants || []).length > 0) {
(o.variants as any[]).forEach((v: any) => { map[`${o.id}::${v.id}`] = 0; });
} else {
map[o.id] = 0;
}
});
return map;
}
function ticketLabel(t: any): string {
const opt = t.registrationOption?.eventOption?.name || "Ticket";
const variant = t.registrationOption?.variant?.name;
return variant ? `${opt}${variant}` : opt;
}
export default function AtTheDoorPage() {
const { user, loading, token } = useAuth();
const router = useRouter();
const [events, setEvents] = useState<any[]>([]);
const [eventId, setEventId] = useState("");
const [eventOptions, setEventOptions] = useState<any[]>([]);
const [showOptionsModal, setShowOptionsModal] = useState(false);
const [pendingUser, setPendingUser] = useState<any | null>(null);
const [pendingEditReg, setPendingEditReg] = useState<any | null>(null);
const [confirming, setConfirming] = useState(false);
const [quantities, setQuantities] = useState<Record<string, number>>({});
// Already-issued ticket quantity per option/variant, keyed like `quantities` — a
// registration being edited can never drop an item below this (tickets are never
// deleted or shrunk, only ever grown, once paid).
const [minQuantities, setMinQuantities] = useState<Record<string, number>>({});
const [showDonationModal, setShowDonationModal] = useState(false);
const [donationUser, setDonationUser] = useState<any | null>(null);
const [showNewAttendeeModal, setShowNewAttendeeModal] = useState(false);
const [newAttendeeSeed, setNewAttendeeSeed] = useState("");
useEffect(() => {
if (!token) return;
(async () => {
try {
const evs = await apiFetch<any[]>("/api/events/all", { authToken: token });
const now = Date.now();
const active = (evs || []).filter(ev => {
const t = new Date(ev.endDate).getTime();
// Registrations/payments are rejected server-side for closed (cashed-up)
// events — don't offer them here.
return !isNaN(t) && t > now && ev.cashupStatus !== 'closed';
});
active.sort(
(a, b) =>
new Date(a.startDate).getTime() -
new Date(b.startDate).getTime()
);
setEvents(active);
if (active.length > 0) {
setEventId(active[0].id); // default
}
} catch {}
})();
}, [token]);
useEffect(() => {
if (!token || !eventId) return;
(async () => {
try {
const ev = await apiFetch(`/api/events/${eventId}`, {
authToken: token
});
setEventOptions(ev.options || ev.eventOptions || []);
} catch {
setEventOptions([]);
}
})();
}, [eventId, token]);
const canView = useMemo(() => {
const role = user?.role;
return role === "admin" || role === "supervisor" || role === "staff";
}, [user]);
useEffect(() => {
if (loading) return;
if (!user) router.replace("/login");
}, [user, loading]);
const [mode, setMode] = useState<Mode>("registration");
const [activeRegistration, setActiveRegistration] = useState<any | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
const handleRegistrationCreated = (registration: any) => {
setActiveRegistration(registration); // 🚀 Jump automatically (see effect below)
};
const handleRegistrationSelected = (registration: any) => {
setActiveRegistration(registration);
};
useEffect(() => {
if (!activeRegistration) return;
const id = setTimeout(() => {
// Fully paid → head straight to check-in; otherwise there's still a
// balance to collect, so go capture payment first.
setMode(activeRegistration.status === "paid" ? "checkin" : "payment");
}, 0);
return () => clearTimeout(id);
}, [activeRegistration]);
const confirmRegistration = async () => {
if (confirming) return;
const opts = Object.entries(quantities)
.filter(([, qty]) => qty > 0)
.map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}) };
});
if (!opts.length) {
setError("Select at least one item");
return;
}
for (const [key, minQty] of Object.entries(minQuantities)) {
if (minQty > 0 && (quantities[key] || 0) < minQty) {
const [eventOptionId, variantId] = key.split("::");
const opt = eventOptions.find((o: any) => o.id === eventOptionId);
const variant = variantId ? (opt?.variants || []).find((v: any) => v.id === variantId) : null;
const label = variant ? `${opt?.name || "item"} (${variant.name})` : (opt?.name || "item");
setError(`Cannot reduce "${label}" below the ${minQty} already issued`);
return;
}
}
setConfirming(true);
try {
if (pendingEditReg) {
// Edit existing registration — replace options via PUT
const res = await apiFetch(`/api/registrations/${pendingEditReg.id}/options`, {
method: "PUT",
authToken: token,
body: { options: opts }
});
setShowOptionsModal(false);
setPendingEditReg(null);
setInfo("Registration updated");
handleRegistrationSelected(res);
} else if (pendingUser) {
// New attendee
const res = await apiFetch("/api/registrations/manual", {
method: "POST",
authToken: token,
body: {
eventId,
guestOnly: pendingUser.guestOnly,
user: {
name: pendingUser.name,
...(pendingUser.email ? { email: pendingUser.email } : {}),
...(pendingUser.phone ? { phoneNumber: pendingUser.phone } : {}),
},
options: opts,
notificationPreference: pendingUser.notifPref,
}
});
setShowOptionsModal(false);
handleRegistrationCreated(res);
}
} catch (e: any) {
setError(e?.message || "Failed");
} finally {
setConfirming(false);
}
};
// Opens the new-attendee modal (pre-filled with whatever the user typed in search)
const handleShowNewAttendee = (seed: string) => {
setNewAttendeeSeed(seed);
setShowNewAttendeeModal(true);
};
// Called when NewAttendeeModal is confirmed — proceed to options selection
const handleNewAttendeeConfirm = ({ name, email, phone, notifPref }: { name: string; email: string; phone: string; notifPref: "email" | "whatsapp" | "both" }) => {
const qtyMap = initQuantities(eventOptions);
// Default main ticket to 1 (first variant if variants exist)
eventOptions.forEach(o => {
if (!o.isMainTicket) return;
if ((o.variants || []).length > 0) {
qtyMap[`${o.id}::${(o.variants as any[])[0].id}`] = 1;
} else {
qtyMap[o.id] = 1;
}
});
setQuantities(qtyMap);
setMinQuantities({});
setPendingUser({ guestOnly: true, name, email: email || null, phone: phone || null, notifPref });
setPendingEditReg(null);
setShowNewAttendeeModal(false);
setShowOptionsModal(true);
};
// Edit an existing registration — pre-fill with current quantities.
// Re-fetches the registration fresh rather than trusting the (possibly up to 5-minutes-stale,
// see DoorRegistrationPanel's polling interval) cached search-result snapshot, so the
// already-issued-ticket floor below is always computed from current data.
const handleEditRegistration = async (reg: any) => {
let freshReg = reg;
try {
freshReg = await apiFetch<any>(`/api/registrations/${reg.id}`, { authToken: token });
} catch (e: any) {
setError(e?.message || "Failed to load latest registration data");
return;
}
const qtyMap = initQuantities(eventOptions);
const regOptions = freshReg.registrationOptions || freshReg.options || [];
regOptions.forEach((ro: any) => {
const key = ro.variantId ? `${ro.eventOptionId}::${ro.variantId}` : ro.eventOptionId;
if (key in qtyMap) qtyMap[key] = ro.quantity || 0;
});
// Already-issued ticket quantity per option/variant — floor for the edit below
const minQtyMap: Record<string, number> = {};
regOptions.forEach((ro: any) => {
const key = ro.variantId ? `${ro.eventOptionId}::${ro.variantId}` : ro.eventOptionId;
const issuedQty = (ro.tickets || []).reduce((s: number, t: any) => s + (t.quantity || 0), 0);
if (issuedQty > 0) minQtyMap[key] = (minQtyMap[key] || 0) + issuedQty;
});
setQuantities(qtyMap);
setMinQuantities(minQtyMap);
setPendingEditReg(freshReg);
setPendingUser(null);
setShowOptionsModal(true);
};
const handlePaymentCaptured = async (registration?: any, partial?: boolean) => {
if (partial && registration) {
setActiveRegistration(registration);
setInfo("Partial payment recorded");
return;
}
if (!registration && !activeRegistration) return;
const reg = registration || activeRegistration;
// Tickets are generated and emailed/WhatsApped automatically server-side
// once a registration reaches "paid" (see paymentController) — no need to
// fetch/print them here, just move straight to checking the attendee in.
setActiveRegistration(reg);
setInfo("Payment recorded — tickets sent. Ready to check in.");
setMode("checkin");
};
const handleDonation = (user?: any) => {
setDonationUser(user || null); // null = anonymous
setShowDonationModal(true);
};
return (
<div className="max-w-6xl mx-auto w-full p-4 sm:p-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 shrink-0">At The Door</h1>
<select
className="border rounded px-3 py-2 text-sm w-full sm:w-72 max-w-full"
value={eventId}
onChange={e => setEventId(e.target.value)}
>
<option value="">Select event</option>
{events.map(ev => (
<option key={ev.id} value={ev.id}>
{ev.title}
</option>
))}
</select>
<button
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 shrink-0 self-start sm:self-auto"
onClick={() => router.push("/dashboard")}
>
Back
</button>
</div>
{!canView && (
<div className="p-3 border rounded bg-yellow-50 text-sm mb-4">
Access denied.
</div>
)}
{error && (
<div className="p-3 mb-3 border rounded bg-red-100 text-red-700 text-sm animate-pulse">
{error}
</div>
)}
{info && (
<div className="p-3 mb-3 border rounded bg-emerald-100 text-emerald-700 text-sm">
{info}
</div>
)}
{/* Mode Buttons */}
<div className="flex gap-2 mb-4 flex-wrap">
{(["registration", "payment", "checkin", "tickets", "refund"] as Mode[]).map(m => (
<button
key={m}
onClick={() => setMode(m)}
className={`px-4 py-2 rounded text-sm font-medium border ${
mode === m
? m === "refund"
? "bg-red-600 text-white border-red-600"
: "bg-indigo-600 text-white border-indigo-600"
: "bg-white hover:bg-gray-50"
}`}
>
{MODE_LABELS[m]}
</button>
))}
</div>
{/* ✅ Panels */}
{mode === "registration" && (
<DoorRegistrationPanel
token={token}
eventId={eventId}
onCreated={handleRegistrationCreated}
onSelected={handleRegistrationSelected}
onEditRegistration={handleEditRegistration}
onNewAttendee={handleShowNewAttendee}
onDonation={handleDonation}
setError={setError}
/>
)}
{mode === "payment" && (
<DoorPaymentPanel
token={token}
registration={activeRegistration}
onSuccess={handlePaymentCaptured}
setError={setError}
/>
)}
{mode === "checkin" && (
<DoorCheckInPanel token={token} eventId={eventId} registration={activeRegistration} setError={setError} setInfo={setInfo} />
)}
{mode === "tickets" && (
<DoorTicketsPanel token={token} eventId={eventId} />
)}
{mode === "refund" && (
<DoorRefundPanel token={token} eventId={eventId} setError={setError} setInfo={setInfo} />
)}
<OptionsModal
open={showOptionsModal}
onClose={() => { setShowOptionsModal(false); setPendingEditReg(null); }}
options={eventOptions}
quantities={quantities}
setQuantities={setQuantities}
minQuantities={minQuantities}
onConfirm={confirmRegistration}
confirming={confirming}
isEdit={!!pendingEditReg}
totalPaid={(pendingEditReg?.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0)}
/>
<DonationModal
open={showDonationModal}
onClose={() => setShowDonationModal(false)}
user={donationUser}
token={token}
eventId={eventId}
setError={setError}
/>
<NewAttendeeModal
open={showNewAttendeeModal}
onClose={() => setShowNewAttendeeModal(false)}
seed={newAttendeeSeed}
onConfirm={handleNewAttendeeConfirm}
/>
</div>
);
}
function DoorRegistrationPanel({ token, eventId, onCreated, onSelected, onEditRegistration, onNewAttendee, onDonation, setError }: any) {
const [search, setSearch] = useState("");
const [allRegs, setAllRegs] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
// Load registrations for this event — once on mount/eventId change, then every 5 min
useEffect(() => {
if (!token || !eventId) return;
let cancelled = false;
const load = async () => {
try {
setLoading(true);
const regs = await apiFetch<any[]>(`/api/registrations/event/${eventId}`, { authToken: token });
if (!cancelled) setAllRegs(regs || []);
} catch {
if (!cancelled) setAllRegs([]);
} finally {
if (!cancelled) setLoading(false);
}
};
load();
const timer = setInterval(load, 300000);
return () => { cancelled = true; clearInterval(timer); };
}, [token, eventId]);
const results = fuzzyFilterRegs(allRegs, search);
return (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex items-center justify-between mb-3">
<div className="text-lg font-semibold">Find / Register</div>
<button
onClick={async () => {
if (!token || !eventId) return;
try {
setLoading(true);
const regs = await apiFetch<any[]>(`/api/registrations/event/${eventId}`, { authToken: token });
setAllRegs(regs || []);
} catch {} finally { setLoading(false); }
}}
className="text-xs px-2 py-1 rounded border hover:bg-gray-50"
>
{loading ? "Loading…" : "Refresh"}
</button>
</div>
<input
className="w-full border rounded px-3 py-3 text-lg"
placeholder="Name, email or phone…"
value={search}
onChange={e => setSearch(e.target.value)}
autoFocus
/>
<div className="mt-3 space-y-2 max-h-96 overflow-auto">
{results.map(r => (
<div key={r.id} className="border rounded p-3">
<div className="flex justify-between items-center">
<div>
<div className="font-medium text-sm">{r.user?.name || "Guest"}</div>
<div className="text-xs text-gray-500">
{r.user?.email && !r.user.email.endsWith("@guest.local") ? r.user.email : ""}
{r.user?.phoneNumber ? ` · ${r.user.phoneNumber}` : ""}
</div>
<div className="text-xs text-gray-400">
{r.status === "paid" ? "PAID ✅" : `UNPAID · ${r.status}`}
</div>
</div>
<div className="flex gap-2">
<button onClick={() => onSelected(r)} className="px-2 py-1 text-xs rounded bg-indigo-600 text-white">Open</button>
<button onClick={() => onEditRegistration(r)} className="px-2 py-1 text-xs rounded bg-emerald-600 text-white">Edit</button>
<button onClick={() => onDonation(r.user)} className="px-2 py-1 text-xs rounded bg-amber-500 text-white">Donation</button>
</div>
</div>
</div>
))}
{/* New attendee row */}
<div
className="border rounded p-3 flex justify-between items-center bg-emerald-50 hover:bg-emerald-100 cursor-pointer"
onClick={() => onNewAttendee(search)}
>
<div className="font-medium text-sm text-emerald-800">
{search ? `New attendee "${search}"…` : "New attendee…"}
</div>
<div className="text-xs text-emerald-600 font-medium">+ Register</div>
</div>
{!loading && allRegs.length === 0 && (
<div className="text-sm text-gray-500">No registrations yet for this event.</div>
)}
</div>
<button
onClick={() => onDonation()}
className="w-full py-3 rounded bg-amber-500 text-white font-semibold mt-3"
>
Anonymous Donation
</button>
</div>
);
}
function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) {
const [amount, setAmount] = useState("");
const [method, setMethod] = useState("card");
const [saving, setSaving] = useState(false);
const [showRefund, setShowRefund] = useState(false);
const [showSendTickets, setShowSendTickets] = useState(false);
if (!registration) {
return (
<div className="border rounded-xl p-6 bg-white shadow-sm text-sm text-gray-500">
No registration selected
</div>
);
}
const options = registration.options || registration.registrationOptions || [];
const payments = registration.payments || [];
const totalValue = options.reduce((sum: number, opt: any) => {
// Use priceSnapshot (authoritative backend price, variant-aware) if available
const price = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
? Number(opt.priceSnapshot)
: (opt.eventOption?.price ?? opt.price ?? 0);
const qty = opt.quantity || 0;
return sum + price * qty;
}, 0);
const paidValue = payments.reduce(
(sum: number, p: any) => sum + (p.amount || 0),
0
);
const balance = Math.max(0, totalValue - paidValue);
const save = async () => {
if (!balance) {
setError("Nothing due on this registration");
return;
}
try {
setSaving(true);
await apiFetch("/api/payments", {
method: "POST",
authToken: token,
body: {
registrationId: registration.id,
amount: parseFloat(amount),
method
}
});
const updated = await apiFetch(`/api/registrations/${registration.id}`, {
authToken: token
});
const updatedOptions = updated.options || updated.registrationOptions || [];
const updatedPayments = updated.payments || [];
const totalValue = updatedOptions.reduce((sum: number, opt: any) => {
const price = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
? Number(opt.priceSnapshot)
: (opt.eventOption?.price ?? opt.price ?? 0);
return sum + price * (opt.quantity || 0);
}, 0);
const paidValue = updatedPayments.reduce(
(sum: number, p: any) => sum + (p.amount || 0),
0
);
const updatedBalance = Math.max(0, totalValue - paidValue);
if (updatedBalance === 0) {
onSuccess(updated); // 🎯 ONLY NOW generate tickets
} else {
onSuccess(updated, true); // 🎯 partial payment flow
}
} catch (e: any) {
setError(e?.message || "Payment failed");
} finally {
setSaving(false);
}
};
return (
<div className="border rounded-xl p-6 bg-white shadow-sm">
<div className="text-xl font-semibold mb-4">Payment</div>
{/* ✅ User */}
<div className="mb-4">
<div className="text-lg font-semibold">
{registration.user?.name || "Guest"}
</div>
</div>
{/* ✅ BIG MONEY BLOCK 😌🔥 */}
<div className="grid grid-cols-3 gap-3 mb-5">
<div className="border rounded-lg p-3 text-center">
<div className="text-xs text-gray-500">TOTAL</div>
<div className="text-lg font-semibold">
R {totalValue.toFixed(2)}
</div>
</div>
<div className="border rounded-lg p-3 text-center">
<div className="text-xs text-gray-500">PAID</div>
<div className="text-lg font-semibold">
R {paidValue.toFixed(2)}
</div>
</div>
<div className="border rounded-lg p-3 text-center bg-emerald-50">
<div className="text-xs text-gray-500">DUE</div>
<div className="text-2xl font-bold text-emerald-600">
R {balance.toFixed(2)}
</div>
</div>
</div>
{/* ✅ Amount + Full Pay */}
<div className="flex gap-2">
<input
className="flex-1 border rounded px-3 py-4 text-2xl font-semibold"
placeholder="Amount"
type="number"
value={amount}
onChange={e => setAmount(e.target.value)}
autoFocus
/>
{balance > 0 && (
<button
onClick={() => setAmount(String(balance))}
className="px-4 rounded bg-indigo-600 text-white text-sm font-medium"
>
Full
</button>
)}
</div>
{/* ✅ Method */}
<select
className="w-full border rounded px-3 py-3 mt-3 text-lg"
value={method}
onChange={e => setMethod(e.target.value)}
>
<option value="card">Card</option>
<option value="cash">Cash</option>
<option value="eft">EFT</option>
</select>
{/* Capture — only show when there's a balance */}
{balance > 0 && (
<button
onClick={save}
disabled={saving || !amount}
className="mt-4 w-full py-4 rounded bg-emerald-600 text-white text-lg font-semibold disabled:opacity-60"
>
{saving ? "Saving…" : "Capture Payment"}
</button>
)}
{/* Print / Send — show when fully paid */}
{balance === 0 && (
<div className="mt-4 flex gap-2">
<button
onClick={() => onSuccess(registration)}
className="flex-1 py-3 rounded bg-indigo-600 text-white text-sm font-medium"
>
Print Tickets
</button>
<button
onClick={() => setShowSendTickets(true)}
className="flex-1 py-3 rounded bg-green-600 text-white text-sm font-medium"
>
Send Tickets
</button>
</div>
)}
{/* Refund */}
{paidValue > 0 && (
<button
onClick={() => setShowRefund(true)}
className="mt-2 w-full py-2 rounded border border-red-300 text-red-600 text-sm hover:bg-red-50"
>
Issue Refund
</button>
)}
<RefundModal
open={showRefund}
onClose={() => setShowRefund(false)}
token={token}
registration={registration}
maxRefund={paidValue}
onRefunded={(updated: any) => {
setShowRefund(false);
onSuccess(updated, true);
}}
setError={setError}
/>
<SendTicketsModal
open={showSendTickets}
onClose={() => setShowSendTickets(false)}
token={token}
registration={registration}
setError={setError}
setInfo={() => {}}
/>
</div>
);
}
function DoorTicketsPanel({ token, eventId }: any) {
const [search, setSearch] = useState("");
const [allTickets, setAllTickets] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [sendTarget, setSendTarget] = useState<any | null>(null);
// Load all tickets for the event once
useEffect(() => {
if (!token || !eventId) return;
(async () => {
try {
setLoading(true);
const res = await apiFetch<any>(`/api/tickets/event/${eventId}`, { authToken: token });
setAllTickets(res?.tickets || res?.data || (Array.isArray(res) ? res : []));
} catch {
setAllTickets([]);
} finally {
setLoading(false);
}
})();
}, [token, eventId]);
const tickets = !search
? allTickets
: (() => {
const q = search.toLowerCase();
const byId = allTickets.filter(t =>
String(t.id).toLowerCase().includes(q) ||
String(t.qrCode || "").toLowerCase().includes(q)
);
if (byId.length > 0) return byId;
if (search.length < 2) return allTickets.filter(t =>
String(t.user?.name || "").toLowerCase().includes(q) ||
String(t.user?.email || "").toLowerCase().includes(q)
);
return allTickets
.map(t => ({ t, score: scoreUser(t.user || {}, search) }))
.filter(x => x.score >= 0.45)
.sort((a, b) => b.score - a.score)
.map(x => x.t);
})();
const load = async () => {
if (!token || !eventId) return;
try {
setLoading(true);
const res = await apiFetch<any>(`/api/tickets/event/${eventId}`, { authToken: token });
setAllTickets(res?.tickets || res?.data || (Array.isArray(res) ? res : []));
} catch {
setAllTickets([]);
} finally {
setLoading(false);
}
};
const print = (ticket: any) => {
const w = window.open("", "_blank");
if (!w) return;
const eventTitle = ticket.event?.title || ticket.eventId || "Event";
const eventDate = ticket.event?.startDate ? new Date(ticket.event.startDate) : null;
const dateStr = eventDate ? eventDate.toLocaleDateString("en-ZA", { day: "numeric", month: "long", year: "numeric" }) : "";
const type = ticketLabel(ticket);
const qty = ticket.quantity || 1;
const holder = ticket.user?.name || ticket.userId || "";
const qrData = encodeURIComponent(ticket.qrCode || ticket.id);
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=${qrData}`;
w.document.write(`<!doctype html><html><head><title>Ticket</title>
<style>
@page { size: A6; margin: 6mm; }
* { box-sizing: border-box; }
body { font-family: Arial, Helvetica, sans-serif; margin: 0; padding: 0; }
.ticket { border: 1.5px solid #222; border-radius: 3mm; padding: 5mm; height: calc(148mm - 12mm); display: flex; flex-direction: column; justify-content: space-between; }
.evt { font-weight: bold; font-size: 15pt; line-height: 1.2; }
.date { font-size: 10pt; color: #555; margin-top: 2mm; }
.type { font-size: 12pt; margin-top: 2mm; color: #222; }
.holder { font-size: 10pt; margin-top: 1mm; color: #555; }
.qty { font-size: 10pt; margin-top: 1mm; }
.qrwrap { display: flex; justify-content: center; align-items: center; flex: 1; }
.qrwrap img { width: 55mm; height: 55mm; display: block; }
.meta { font-size: 9pt; text-align: center; color: #666; word-break: break-all; }
</style>
</head><body>
<div class="ticket">
<div>
<div class="evt">${eventTitle}</div>
${dateStr ? `<div class="date">${dateStr}</div>` : ""}
<div class="type">${type}</div>
${holder ? `<div class="holder">${holder}</div>` : ""}
<div class="qty">Qty: <strong>${qty}</strong></div>
</div>
<div class="qrwrap"><img src="${qrSrc}" alt="QR" /></div>
<div class="meta">${ticket.id}</div>
</div>
<script>
(function(){
function go(){
var imgs=Array.prototype.slice.call(document.images);
if(!imgs.length){window.print();return;}
var n=imgs.length;
function done(){if(--n<=0)setTimeout(function(){window.print();},150);}
imgs.forEach(function(i){if(i.complete)done();else{i.addEventListener('load',done,{once:true});i.addEventListener('error',done,{once:true});}});
}
if(document.readyState==='complete')go();else window.addEventListener('load',go);
})();
</script>
</body></html>`);
w.document.close();
w.focus();
};
return (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex items-center justify-between mb-3">
<div className="text-lg font-semibold">Tickets</div>
<button onClick={load} className="text-xs px-2 py-1 rounded border hover:bg-gray-50">
{loading ? "Loading…" : "Refresh"}
</button>
</div>
<div className="flex gap-2">
<input
className="flex-1 border rounded px-3 py-3 text-lg"
placeholder="Filter by name, phone or ticket ID…"
value={search}
onChange={e => setSearch(e.target.value)}
autoFocus
/>
</div>
{loading && (
<div className="text-sm text-gray-500 mt-2">
Searching
</div>
)}
<div className="mt-3 space-y-2 max-h-96 overflow-auto">
{tickets.map(t => (
<div
key={t.id}
className="border rounded p-2 flex justify-between items-center"
>
<div className="text-sm">
<div className="font-medium">
{t.user?.name || "Guest"}
</div>
<div className="text-xs text-gray-500">
{ticketLabel(t)}
</div>
</div>
<div className="flex gap-1">
<button
onClick={() => print(t)}
className="px-3 py-1 text-xs rounded bg-emerald-600 text-white"
>
Print
</button>
<button
onClick={() => setSendTarget(t)}
className="px-3 py-1 text-xs rounded bg-green-600 text-white"
>
Send
</button>
</div>
</div>
))}
{!loading && tickets.length === 0 && (
<div className="text-sm text-gray-500">
No tickets found
</div>
)}
</div>
{sendTarget && (
<SendTicketsModal
open={true}
onClose={() => setSendTarget(null)}
token={token}
registration={{
id: sendTarget.registrationOption?.registration?.id || sendTarget.registrationOption?.registrationId,
user: sendTarget.user,
}}
setError={() => {}}
setInfo={() => {}}
/>
)}
</div>
);
}
function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: any) {
const [ticketsForEvent, setTicketsForEvent] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [qtyByTicket, setQtyByTicket] = useState<Record<string, number>>({});
const [submittingId, setSubmittingId] = useState<string | null>(null);
const load = async () => {
if (!token || !eventId) return;
try {
setLoading(true);
const res = await apiFetch<any>(`/api/tickets/event/${eventId}`, { authToken: token });
const tickets = res?.tickets || res?.data || (Array.isArray(res) ? res : []);
setTicketsForEvent(tickets.filter((t: any) => t.registrationOption?.eventOption?.isMainTicket));
} catch {
setTicketsForEvent([]);
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, [token, eventId]);
if (!registration) {
return (
<div className="border rounded-xl p-6 bg-white shadow-sm text-sm text-gray-500">
No registration selected
</div>
);
}
const myTickets = ticketsForEvent
.filter(t => t.registrationOption?.registration?.id === registration.id)
.map(t => {
const totalRedeemed = (t.usages || []).reduce((s: number, u: any) => s + (u.quantityRedeemed || 1), 0);
const remaining = (t.quantity || 1) - totalRedeemed;
return { ...t, totalRedeemed, remaining };
});
const qtyFor = (t: any) => qtyByTicket[t.id] ?? (t.remaining > 0 ? t.remaining : 1);
const setQtyFor = (t: any, n: number) =>
setQtyByTicket(q => ({ ...q, [t.id]: Math.max(1, Math.min(t.remaining || 1, n)) }));
const commit = async (ticket: any) => {
const qty = qtyFor(ticket);
try {
setSubmittingId(ticket.id);
const res = await apiFetch<any>(
`/api/tickets/scan/${encodeURIComponent(ticket.qrCode)}?eventId=${encodeURIComponent(eventId)}`,
{ method: "POST", authToken: token, body: { qty } }
);
setInfo(`${res?.qtyRedeemed ?? qty} checked in for ${registration.user?.name || "guest"}${
res?.remaining > 0 ? ` — ${res.remaining} remaining` : " — fully checked in"
}. Confirmation sent.`);
await load();
} catch (e: any) {
setError(e?.message || "Check-in failed");
} finally {
setSubmittingId(null);
}
};
return (
<div className="border rounded-xl p-6 bg-white shadow-sm">
<div className="text-xl font-semibold mb-4">Check-In</div>
{/* ✅ User */}
<div className="mb-4">
<div className="text-lg font-semibold">
{registration.user?.name || "Guest"}
</div>
</div>
{loading && (
<div className="text-sm text-gray-500">Loading</div>
)}
{!loading && myTickets.length === 0 && (
<div className="text-sm text-gray-500">
No Main Tickets on this registration
</div>
)}
<div className="space-y-4">
{myTickets.map(t => {
const qty = qtyFor(t);
return (
<div key={t.id} className="border rounded-lg p-4">
<div className="text-sm font-medium mb-3">{ticketLabel(t)}</div>
<div className="grid grid-cols-3 gap-3 mb-4">
<div className="border rounded-lg p-3 text-center">
<div className="text-xs text-gray-500">TOTAL</div>
<div className="text-lg font-semibold">{t.quantity || 1}</div>
</div>
<div className="border rounded-lg p-3 text-center">
<div className="text-xs text-gray-500">CHECKED IN</div>
<div className="text-lg font-semibold">{t.totalRedeemed}</div>
</div>
<div className="border rounded-lg p-3 text-center bg-emerald-50">
<div className="text-xs text-gray-500">REMAINING</div>
<div className="text-2xl font-bold text-emerald-600">{t.remaining}</div>
</div>
</div>
{t.remaining > 0 ? (
<div className="flex items-center gap-3">
<button
onClick={() => setQtyFor(t, qty - 1)}
className="w-9 h-9 border rounded flex items-center justify-center text-lg font-bold"
>
</button>
<div className="w-10 text-center text-lg font-semibold">{qty}</div>
<button
onClick={() => setQtyFor(t, qty + 1)}
className="w-9 h-9 border rounded flex items-center justify-center text-lg font-bold bg-emerald-50 border-emerald-300 text-emerald-700"
>
+
</button>
<button
onClick={() => commit(t)}
disabled={submittingId === t.id}
className="ml-auto px-4 py-2 text-sm rounded bg-indigo-600 text-white disabled:opacity-50"
>
{submittingId === t.id ? "Checking in…" : `Check In ${qty}`}
</button>
</div>
) : (
<div className="text-sm text-emerald-600 font-medium">Fully checked in </div>
)}
</div>
);
})}
</div>
</div>
);
}
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0 }: any) {
if (!open) return null;
const safeOptions: any[] = options || [];
const total = safeOptions.reduce((sum: number, o: any) => {
if ((o.variants || []).length > 0) {
return sum + (o.variants as any[]).reduce((vs: number, v: any) =>
vs + (quantities[`${o.id}::${v.id}`] || 0) * effectiveVariantUnit(o, v), 0);
}
return sum + (quantities[o.id] || 0) * effectiveOptionUnit(o);
}, 0);
const belowPaid = isEdit && total < totalPaid;
const belowIssued = isEdit && Object.entries(minQuantities).some(
([key, minQty]: [string, any]) => minQty > 0 && (quantities[key] || 0) < minQty
);
const stepper = (key: string, delta: number) =>
setQuantities((q: Record<string, number>) => ({ ...q, [key]: Math.max(minQuantities[key] || 0, (q[key] || 0) + delta) }));
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl w-full max-w-2xl shadow-xl flex flex-col max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="p-5 border-b flex-shrink-0">
<div className="text-xl font-semibold">
{isEdit ? "Edit Registration" : "Select Items"}
</div>
{isEdit && totalPaid > 0 && (
<div className="text-sm text-gray-500 mt-1">
Already paid: <strong>R {Number(totalPaid).toFixed(2)}</strong> new total must be at least this amount.
</div>
)}
</div>
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto overscroll-contain p-5 space-y-3">
{safeOptions.map((opt: any) => {
const hasVariants = (opt.variants || []).length > 0;
if (hasVariants) {
return (
<div key={opt.id} className="border rounded-xl overflow-hidden">
<div className="px-4 py-2 bg-gray-50 border-b font-medium text-sm">
{opt.name}
{opt.isMainTicket && <span className="ml-1.5 text-xs text-blue-600 font-normal"> Main</span>}
</div>
{(opt.variants as any[]).map((v: any) => {
const unit = effectiveVariantUnit(opt, v);
const basePrice = v.price !== null && v.price !== undefined ? v.price : opt.price;
const key = `${opt.id}::${v.id}`;
const minQty = minQuantities[key] || 0;
return (
<div key={v.id} className="flex items-center justify-between px-4 py-2.5 border-b last:border-b-0">
<div>
<div className="text-sm">{v.name}</div>
<div className="text-xs text-gray-500">
R {unit.toFixed(2)}
{unit < basePrice && basePrice > 0 && <span className="ml-1 text-green-600">(early bird)</span>}
</div>
{minQty > 0 && <div className="text-xs text-gray-400">{minQty} already issued</div>}
</div>
<div className="flex items-center gap-3">
<button onClick={() => stepper(key, -1)} disabled={(quantities[key] || 0) <= minQty} className="w-8 h-8 border rounded flex items-center justify-center text-lg font-bold disabled:opacity-40"></button>
<div className="w-6 text-center text-base font-semibold">{quantities[key] || 0}</div>
<button onClick={() => stepper(key, 1)} className="w-8 h-8 border rounded flex items-center justify-center text-lg font-bold bg-emerald-50 border-emerald-300 text-emerald-700">+</button>
</div>
</div>
);
})}
</div>
);
}
const unit = effectiveOptionUnit(opt);
const minQty = minQuantities[opt.id] || 0;
return (
<div key={opt.id} className="border rounded-xl p-3">
<div className="font-medium">{opt.name}</div>
<div className="text-xs text-gray-500">
R {unit.toFixed(2)}
{unit < opt.price && opt.price > 0 && <span className="ml-1 text-green-600">(early bird, was R {opt.price.toFixed(2)})</span>}
{opt.isMainTicket ? " • Main" : ""}
</div>
{minQty > 0 && <div className="text-xs text-gray-400">{minQty} already issued</div>}
<div className="flex gap-3 items-center mt-2">
<button onClick={() => stepper(opt.id, -1)} disabled={(quantities[opt.id] || 0) <= minQty} className="w-8 h-8 border rounded flex items-center justify-center text-lg font-bold disabled:opacity-40"></button>
<div className="w-6 text-center text-lg font-semibold">{quantities[opt.id] || 0}</div>
<button onClick={() => stepper(opt.id, 1)} className="w-8 h-8 border rounded flex items-center justify-center text-lg font-bold bg-emerald-50 border-emerald-300 text-emerald-700">+</button>
</div>
</div>
);
})}
</div>
{/* Footer */}
<div className="p-4 border-t flex-shrink-0 bg-white">
{belowPaid && (
<div className="mb-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">
New total (R {total.toFixed(2)}) is less than amount already paid (R {Number(totalPaid).toFixed(2)}). Please increase the selection.
</div>
)}
{belowIssued && (
<div className="mb-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">
One or more items are below the quantity already issued as tickets. Please increase the selection.
</div>
)}
<div className="flex justify-between items-center">
<div className="text-lg font-semibold">Total: R {total.toFixed(2)}</div>
<div className="flex gap-2">
<button onClick={onClose} className="px-4 py-2 border rounded" disabled={confirming}>
Cancel
</button>
<button
onClick={onConfirm}
disabled={confirming || belowPaid || belowIssued}
className="px-4 py-2 rounded bg-emerald-600 text-white disabled:opacity-60 min-w-[140px]"
>
{confirming
? "Processing…"
: isEdit ? "Save Changes" : "Confirm Registration"}
</button>
</div>
</div>
</div>
</div>
</div>
);
}
function DonationModal({ open, onClose, user, token, eventId, onSuccess, setError }: any) {
const [amount, setAmount] = useState("");
const [method, setMethod] = useState("cash");
const [saving, setSaving] = useState(false);
if (!open) return null;
const saveDonation = async () => {
const amt = parseFloat(amount);
if (!amt || amt <= 0) {
setError("Enter valid amount");
return;
}
try {
setSaving(true);
await apiFetch("/api/payments", {
method: "POST",
authToken: token,
body: {
amount: amt,
method,
userId: user?.id || undefined,
eventId,
isDonation: true
}
});
onSuccess?.();
setAmount("");
onClose();
} catch (e: any) {
setError(e?.message || "Donation failed");
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-2xl p-6 w-full max-w-md shadow-xl">
<div className="text-xl font-semibold mb-2">
Donation
</div>
<div className="text-sm text-gray-500 mb-4">
{user ? user.name : "Anonymous Donation"}
</div>
<input
className="w-full border rounded px-3 py-3 text-2xl font-semibold"
placeholder="Amount"
type="number"
value={amount}
onChange={e => setAmount(e.target.value)}
autoFocus
/>
<select
className="w-full border rounded px-3 py-3 mt-3"
value={method}
onChange={e => setMethod(e.target.value)}
>
<option value="cash">Cash</option>
<option value="card">Card</option>
<option value="eft">EFT</option>
</select>
<div className="flex gap-2 mt-4">
<button
onClick={onClose}
className="flex-1 py-3 border rounded"
>
Cancel
</button>
<button
onClick={saveDonation}
disabled={saving}
className="flex-1 py-3 rounded bg-emerald-600 text-white font-semibold"
>
{saving ? "Saving…" : "Capture"}
</button>
</div>
</div>
</div>
);
}
function SendTicketsModal({ open, onClose, token, registration, setError, setInfo }: any) {
const [channel, setChannel] = useState<"email" | "whatsapp" | "both">("email");
const [phone, setPhone] = useState("");
const [email, setEmail] = useState("");
const [sending, setSending] = useState(false);
const [localError, setLocalError] = useDismissingState("");
const [localInfo, setLocalInfo] = useState("");
useEffect(() => {
if (open && registration) {
setPhone(registration.user?.phoneNumber || "");
setEmail(registration.user?.email?.endsWith("@guest.local") ? "" : registration.user?.email || "");
setLocalError("");
setLocalInfo("");
}
}, [open, registration]);
if (!open) return null;
const send = async () => {
setLocalError("");
if ((channel === "email" || channel === "both") && !email.trim()) {
setLocalError("Email address is required for email delivery.");
return;
}
if ((channel === "whatsapp" || channel === "both") && !phone.trim()) {
setLocalError("Phone number is required for WhatsApp delivery.");
return;
}
setSending(true);
try {
await apiFetch("/api/tickets/send-to", {
method: "POST",
authToken: token,
body: {
registrationId: registration.id,
channel,
overrideEmail: email.trim() || undefined,
overridePhone: phone.trim() || undefined,
},
});
setLocalInfo("Tickets sent successfully.");
setTimeout(() => { setLocalInfo(""); onClose(); }, 7000);
} catch (e: any) {
setLocalError(e?.message || "Failed to send tickets");
} finally {
setSending(false);
}
};
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl w-full max-w-md shadow-xl">
<div className="p-6 space-y-4">
<div className="text-xl font-semibold">Send Tickets</div>
<div className="text-sm text-gray-600">
<span className="font-medium">{registration?.user?.name || "Guest"}</span>
</div>
<div>
<label className="block text-sm font-medium mb-1">Send via</label>
<div className="flex gap-2">
{(["email", "whatsapp", "both"] as const).map((c) => (
<button
key={c}
type="button"
onClick={() => setChannel(c)}
className={`flex-1 py-2 rounded border text-sm font-medium ${
channel === c
? c === "whatsapp" ? "bg-green-600 text-white border-green-600"
: c === "both" ? "bg-indigo-600 text-white border-indigo-600"
: "bg-blue-600 text-white border-blue-600"
: "bg-white text-gray-700 border-gray-300 hover:bg-gray-50"
}`}
>
{c === "both" ? "Both" : c === "whatsapp" ? "WhatsApp" : "Email"}
</button>
))}
</div>
</div>
{(channel === "email" || channel === "both") && (
<div>
<label className="block text-sm font-medium mb-1">Email address</label>
<input
className="w-full border rounded px-3 py-2 text-sm"
type="email"
placeholder="recipient@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
)}
{(channel === "whatsapp" || channel === "both") && (
<div>
<label className="block text-sm font-medium mb-1">WhatsApp number</label>
<input
className="w-full border rounded px-3 py-2 text-sm"
type="tel"
placeholder="e.g. 0821234567"
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
</div>
)}
{localError && <p className="text-sm text-red-600">{localError}</p>}
{localInfo && <p className="text-sm text-emerald-600">{localInfo}</p>}
<div className="flex gap-2 pt-1">
<button type="button" onClick={onClose} className="flex-1 py-2 border rounded text-sm" disabled={sending}>
Cancel
</button>
<button
type="button"
onClick={send}
disabled={sending}
className="flex-1 py-2 rounded bg-indigo-600 text-white text-sm font-medium disabled:opacity-60"
>
{sending ? "Sending…" : "Send Tickets"}
</button>
</div>
</div>
</div>
</div>
);
}
function RefundModal({ open, onClose, token, registration, maxRefund, onRefunded, setError }: any) {
const [amount, setAmount] = useState("");
const [method, setMethod] = useState("cash");
const [reason, setReason] = useState("");
const [saving, setSaving] = useState(false);
const [localError, setLocalError] = useDismissingState("");
useEffect(() => {
if (open) { setAmount(""); setReason(""); setLocalError(""); }
}, [open]);
if (!open) return null;
const save = async (e: React.FormEvent) => {
e.preventDefault();
const amt = parseFloat(amount);
if (!amt || amt <= 0) { setLocalError("Enter a valid amount."); return; }
if (amt > maxRefund) { setLocalError(`Cannot exceed total paid (R ${Number(maxRefund).toFixed(2)}).`); return; }
setSaving(true);
setLocalError("");
try {
await apiFetch("/api/payments/refund", {
method: "POST",
authToken: token,
body: {
userId: registration.userId,
registrationId: registration.id,
amount: amt,
method,
reason: reason || undefined,
}
});
// Re-fetch registration to get updated status & payments
const updated = await apiFetch<any>(`/api/registrations/${registration.id}`, { authToken: token });
onRefunded(updated);
} catch (e: any) {
setLocalError(e?.message || "Refund failed.");
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl w-full max-w-md shadow-xl">
<form onSubmit={save} className="p-6 space-y-4">
<div className="text-xl font-semibold text-red-700">Issue Refund</div>
<div className="text-sm text-gray-600">
<span className="font-medium">{registration.user?.name || "Guest"}</span>
<span className="ml-2 text-gray-400">· Total paid: R {Number(maxRefund).toFixed(2)}</span>
</div>
<div>
<label className="block text-sm font-medium mb-1">Refund amount</label>
<div className="flex gap-2">
<input
className="flex-1 border rounded px-3 py-3 text-2xl font-semibold"
type="number"
step="0.01"
min="0.01"
max={maxRefund}
placeholder="0.00"
value={amount}
onChange={e => setAmount(e.target.value)}
autoFocus
/>
<button
type="button"
onClick={() => setAmount(String(maxRefund))}
className="px-3 rounded border text-sm"
>
Full
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">Method</label>
<select
className="w-full border rounded px-3 py-2"
value={method}
onChange={e => setMethod(e.target.value)}
>
<option value="cash">Cash</option>
<option value="card">Card</option>
<option value="eft">EFT</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Reason <span className="text-gray-400 font-normal">(optional)</span></label>
<input
className="w-full border rounded px-3 py-2 text-sm"
placeholder="e.g. Cancelled, overpayment…"
value={reason}
onChange={e => setReason(e.target.value)}
/>
</div>
{localError && <p className="text-sm text-red-600">{localError}</p>}
<div className="flex gap-2 pt-1">
<button type="button" onClick={onClose} className="flex-1 py-2 border rounded text-sm" disabled={saving}>
Cancel
</button>
<button
type="submit"
disabled={saving || !amount}
className="flex-1 py-2 rounded bg-red-600 text-white text-sm font-medium disabled:opacity-60"
>
{saving ? "Processing…" : "Confirm Refund"}
</button>
</div>
</form>
</div>
</div>
);
}
function DoorRefundPanel({ token, eventId, setError, setInfo }: any) {
const [search, setSearch] = useState("");
const [allRegs, setAllRegs] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [selectedReg, setSelectedReg] = useState<any | null>(null);
const [showRefund, setShowRefund] = useState(false);
const load = async () => {
if (!token || !eventId) return;
try {
setLoading(true);
const regs = await apiFetch<any[]>(`/api/registrations/event/${eventId}`, { authToken: token });
setAllRegs(regs || []);
} catch {
setAllRegs([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, [token, eventId]);
// Only show registrations that have at least one payment
const paidRegs = allRegs.filter(r =>
(r.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0) > 0
);
const results = fuzzyFilterRegs(paidRegs, search);
const handleRefunded = async (updated: any) => {
setShowRefund(false);
setSelectedReg(updated);
setInfo("Refund recorded");
// Refresh list
const regs = await apiFetch<any[]>(`/api/registrations/event/${eventId}`, { authToken: token }).catch(() => null);
if (regs) setAllRegs(regs);
};
const maxRefund = selectedReg
? (selectedReg.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0)
: 0;
return (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex items-center justify-between mb-3">
<div className="text-lg font-semibold text-red-700">Refunds</div>
<button
onClick={load}
className="text-xs px-2 py-1 rounded border hover:bg-gray-50"
>
{loading ? "Loading…" : "Refresh"}
</button>
</div>
{!selectedReg ? (
<>
<input
className="w-full border rounded px-3 py-3 text-lg mb-3"
placeholder="Search name, email or phone…"
value={search}
onChange={e => setSearch(e.target.value)}
autoFocus
/>
<div className="space-y-2 max-h-[32rem] overflow-auto">
{results.map(r => {
const paidValue = (r.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0);
return (
<div key={r.id} className="border rounded p-3 flex justify-between items-center">
<div>
<div className="font-medium text-sm">{r.user?.name || "Guest"}</div>
<div className="text-xs text-gray-500">
{r.user?.email && !r.user.email.endsWith("@guest.local") ? r.user.email : ""}
{r.user?.phoneNumber ? ` · ${r.user.phoneNumber}` : ""}
</div>
<div className="text-xs text-gray-400">
Paid: R {paidValue.toFixed(2)} · {r.status}
</div>
</div>
<button
onClick={() => setSelectedReg(r)}
className="px-3 py-1.5 text-sm rounded bg-red-600 text-white hover:bg-red-700"
>
Refund
</button>
</div>
);
})}
{!loading && results.length === 0 && (
<div className="text-sm text-gray-500 py-4 text-center">
No paid registrations found for this event.
</div>
)}
</div>
</>
) : (
<div>
<button
onClick={() => setSelectedReg(null)}
className="text-sm text-gray-500 hover:text-gray-700 mb-4 flex items-center gap-1"
>
Back to list
</button>
<div className="border rounded-lg p-4 mb-4">
<div className="font-semibold text-lg">{selectedReg.user?.name || "Guest"}</div>
<div className="text-sm text-gray-500">
{selectedReg.user?.email && !selectedReg.user.email.endsWith("@guest.local") ? selectedReg.user.email : ""}
{selectedReg.user?.phoneNumber ? ` · ${selectedReg.user.phoneNumber}` : ""}
</div>
<div className="mt-3 grid grid-cols-2 gap-3">
<div className="border rounded p-3 text-center">
<div className="text-xs text-gray-500">TOTAL</div>
<div className="font-semibold">
R {(selectedReg.options || selectedReg.registrationOptions || []).reduce((s: number, o: any) => {
const price = o.priceSnapshot !== null && o.priceSnapshot !== undefined
? Number(o.priceSnapshot)
: (o.eventOption?.price || o.price || 0);
return s + price * (o.quantity || 0);
}, 0).toFixed(2)}
</div>
</div>
<div className="border rounded p-3 text-center bg-red-50">
<div className="text-xs text-gray-500">PAID</div>
<div className="font-semibold text-red-700">R {maxRefund.toFixed(2)}</div>
</div>
</div>
{(selectedReg.payments || []).length > 0 && (
<div className="mt-3">
<div className="text-xs font-medium text-gray-500 mb-1">Payment history</div>
<div className="space-y-1">
{selectedReg.payments.map((p: any, i: number) => (
<div key={i} className="flex justify-between text-sm">
<span className={p.amount < 0 ? "text-red-600" : ""}>
{p.amount < 0 ? "Refund" : "Payment"} {p.method || "—"}
{p.reason ? ` (${p.reason})` : ""}
</span>
<span className={p.amount < 0 ? "text-red-600 font-medium" : ""}>
R {Number(p.amount).toFixed(2)}
</span>
</div>
))}
</div>
</div>
)}
</div>
{maxRefund > 0 ? (
<button
onClick={() => setShowRefund(true)}
className="w-full py-3 rounded bg-red-600 text-white font-semibold hover:bg-red-700"
>
Issue Refund
</button>
) : (
<div className="text-center text-sm text-gray-500 py-2">
Nothing to refund net paid is R 0.00.
</div>
)}
</div>
)}
<RefundModal
open={showRefund}
onClose={() => setShowRefund(false)}
token={token}
registration={selectedReg}
maxRefund={maxRefund}
onRefunded={handleRefunded}
setError={setError}
/>
</div>
);
}
function NewAttendeeModal({ open, onClose, seed, onConfirm }: {
open: boolean;
onClose: () => void;
seed: string;
onConfirm: (data: { name: string; email: string; phone: string; notifPref: "email" | "whatsapp" | "both" }) => void;
}) {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [phone, setPhone] = useState("");
const [notifPref, setNotifPref] = useState<"email" | "whatsapp" | "both">("email");
const [err, setErr] = useState("");
// Pre-fill name from whatever was typed in search
useEffect(() => {
if (open) {
setName(seed || "");
setEmail("");
setPhone("");
setNotifPref("email");
setErr("");
}
}, [open, seed]);
if (!open) return null;
const derivedPref = email.trim() && phone.trim() ? notifPref : phone.trim() ? "whatsapp" : "email";
const submit = (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) { setErr("Name is required."); return; }
if (!email.trim() && !phone.trim()) { setErr("Provide at least an email or phone number."); return; }
onConfirm({ name: name.trim(), email: email.trim(), phone: phone.trim(), notifPref: derivedPref });
};
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl w-full max-w-md shadow-xl">
<form onSubmit={submit} className="p-6 space-y-4">
<div className="text-xl font-semibold">New Attendee</div>
<div>
<label className="block text-sm font-medium mb-1">Full name *</label>
<input
className="w-full border rounded px-3 py-2 text-sm"
value={name}
onChange={e => setName(e.target.value)}
placeholder="Full name"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Email</label>
<input
className="w-full border rounded px-3 py-2 text-sm"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="Email address"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Cell number</label>
<input
className="w-full border rounded px-3 py-2 text-sm"
type="tel"
value={phone}
onChange={e => {
const v = e.target.value;
setPhone(v);
if (v.trim() && !email.trim()) setNotifPref("whatsapp");
else if (!v.trim() && email.trim()) setNotifPref("email");
}}
placeholder="+27…"
/>
</div>
{/* Preference selector — shown only when both channels are provided */}
{email.trim() && phone.trim() && (
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Send tickets via</label>
<div className="flex rounded-lg border overflow-hidden text-xs font-medium">
{(["email", "whatsapp", "both"] as const).map((p) => (
<button
key={p}
type="button"
onClick={() => setNotifPref(p)}
className={`flex-1 py-2 transition-colors ${
notifPref === p
? p === "whatsapp" ? "bg-green-600 text-white"
: p === "both" ? "bg-indigo-600 text-white"
: "bg-blue-600 text-white"
: "bg-white text-gray-600 hover:bg-gray-50"
}`}
>
{p === "email" ? "Email" : p === "whatsapp" ? "WhatsApp" : "Both"}
</button>
))}
</div>
</div>
)}
<p className="text-xs text-gray-500">At least one of email or cell number is required. If no email is provided, a guest account is created.</p>
{err && <p className="text-sm text-red-600">{err}</p>}
<div className="flex gap-2 pt-2">
<button type="button" onClick={onClose} className="flex-1 py-2 border rounded text-sm">Cancel</button>
<button type="submit" className="flex-1 py-2 rounded bg-emerald-600 text-white text-sm font-medium">Next Choose Items</button>
</div>
</form>
</div>
</div>
);
}