- Add matching lucide-react icons to tab/mode switcher buttons on payments, at-the-door, manual, email-attendees, whatsapp-attendees, and admin cashup pages, mirroring icons already used in their help menus - Fix navbar Logout button sitting lower than other nav links (missing border/padding classes that other links use for their active-underline) - Fix stat card labels getting truncated on mobile by removing the ellipsis-cut label and widening the mobile grid to one column - Fix Revenue trend / Top performing events rendering outside the viewport on mobile by containing horizontal overflow and truncating long event titles in the table
688 lines
32 KiB
TypeScript
688 lines
32 KiB
TypeScript
"use client";
|
|
|
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { useRouter } from "next/navigation";
|
|
import { apiFetch, fetchAllUsers } from "@/lib/api";
|
|
import { scoreUser } from "@/lib/fuzzyMatch";
|
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
|
import { UserPlus, CreditCard } from "lucide-react";
|
|
|
|
// ─── 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 === 0) return base;
|
|
const now = new Date();
|
|
const applicable = 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 applicable.length > 0 ? applicable[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 variantTiers = allTiers.filter((t: any) => t.variantId === variant.id);
|
|
const tiers = variantTiers.length > 0 ? variantTiers : allTiers.filter((t: any) => !t.variantId);
|
|
if (tiers.length === 0) return base;
|
|
const now = new Date();
|
|
const applicable = 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 applicable.length > 0 ? applicable[0].price : base;
|
|
}
|
|
|
|
function fmtPrice(n: number) {
|
|
return n === 0 ? "Free" : `R ${n.toFixed(2)}`;
|
|
}
|
|
|
|
// Effective unit price for an existing registration option (as returned by /api/registrations).
|
|
// Prefers priceSnapshot (authoritative price captured at registration time) and only falls
|
|
// back to a live early-bird calculation for legacy rows without a snapshot.
|
|
function optionUnitPrice(opt: any): number {
|
|
if (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) {
|
|
return Number(opt.priceSnapshot);
|
|
}
|
|
const eo = opt.eventOption;
|
|
if (opt.variant) return effectiveVariantUnit(eo, opt.variant);
|
|
return effectiveOptionUnit(eo);
|
|
}
|
|
|
|
// ─── Component ───────────────────────────────────────────────────────────────
|
|
|
|
export default function ManualRegistrationPage() {
|
|
const { user, loading, token } = useAuth();
|
|
const router = useRouter();
|
|
|
|
const canView = useMemo(() => {
|
|
const role = user?.role;
|
|
return role === "admin" || role === "supervisor";
|
|
}, [user]);
|
|
|
|
useEffect(() => {
|
|
if (loading) return;
|
|
if (!user) router.replace("/login");
|
|
}, [user, loading, router]);
|
|
|
|
const [events, setEvents] = useState<any[]>([]);
|
|
const [selectedEventId, setSelectedEventId] = useState<string>("");
|
|
const [options, setOptions] = useState<any[]>([]);
|
|
|
|
// All system users for fuzzy lookup
|
|
const [allUsers, setAllUsers] = useState<any[]>([]);
|
|
|
|
const [guest, setGuest] = useState({ name: "", email: "", phoneNumber: "" });
|
|
const [registerAsGuest, setRegisterAsGuest] = useState(false);
|
|
const [notifPref, setNotifPref] = useState<"email" | "whatsapp" | "both">("email");
|
|
const [quantities, setQuantities] = useState<Record<string, number>>({});
|
|
|
|
// User search state
|
|
const [userQuery, setUserQuery] = useState("");
|
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
|
const searchRef = useRef<HTMLDivElement>(null);
|
|
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [message, setMessage] = useDismissingState<string | null>(null);
|
|
const [error, setError] = useDismissingState<string | null>(null);
|
|
|
|
// ── Tabs ──────────────────────────────────────────────────────────────────
|
|
const [tab, setTab] = useState<"register" | "payment">("register");
|
|
|
|
// ── Record Payment tab state ─────────────────────────────────────────────
|
|
const [allRegistrations, setAllRegistrations] = useState<any[]>([]);
|
|
const [payUserId, setPayUserId] = useState<string>("");
|
|
const [payUserQuery, setPayUserQuery] = useState("");
|
|
const [payDropdownOpen, setPayDropdownOpen] = useState(false);
|
|
const paySearchRef = useRef<HTMLDivElement>(null);
|
|
const [payRegistrationId, setPayRegistrationId] = useState<string>("");
|
|
const [payAmount, setPayAmount] = useState<string>("");
|
|
const [payMethod, setPayMethod] = useState<string>("cash");
|
|
const [payPaidAtLocal, setPayPaidAtLocal] = useState<string>("");
|
|
const [paySubmitting, setPaySubmitting] = useState(false);
|
|
const [payMessage, setPayMessage] = useDismissingState<string | null>(null);
|
|
const [payError, setPayError] = useDismissingState<string | null>(null);
|
|
|
|
// Load all users for client-side fuzzy matching
|
|
useEffect(() => {
|
|
if (!token) return;
|
|
fetchAllUsers(token)
|
|
.then(users => setAllUsers(users))
|
|
.catch(() => {});
|
|
}, [token]);
|
|
|
|
// Load all registrations for the Record Payment tab (embeds payments, so outstanding
|
|
// balances can be computed without a per-registration fetch loop).
|
|
useEffect(() => {
|
|
if (!token) return;
|
|
apiFetch<any[]>("/api/registrations", { authToken: token })
|
|
.then(regs => setAllRegistrations(Array.isArray(regs) ? regs : []))
|
|
.catch(() => {});
|
|
}, [token]);
|
|
|
|
const regOutstanding = useMemo(() => {
|
|
const map: Record<string, { totalDue: number; totalPaid: number; outstanding: number }> = {};
|
|
for (const r of allRegistrations) {
|
|
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt) * (opt.quantity || 0), 0);
|
|
const totalPaid = (r.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0);
|
|
map[r.id] = { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid) };
|
|
}
|
|
return map;
|
|
}, [allRegistrations]);
|
|
|
|
const payMatchedUsers = useMemo(() => {
|
|
if (payUserQuery.trim().length < 2) return [];
|
|
return allUsers
|
|
.map(u => ({ u, score: scoreUser(u, payUserQuery) }))
|
|
.filter(x => x.score >= 0.45)
|
|
.sort((a, b) => b.score - a.score)
|
|
.slice(0, 6)
|
|
.map(x => x.u);
|
|
}, [payUserQuery, allUsers]);
|
|
|
|
const selectPayUser = (u: any) => {
|
|
setPayUserId(u.id);
|
|
setPayUserQuery(u.name || "");
|
|
setPayDropdownOpen(false);
|
|
setPayRegistrationId("");
|
|
};
|
|
|
|
// Close payment-tab user dropdown on outside click
|
|
useEffect(() => {
|
|
const handler = (e: MouseEvent) => {
|
|
if (paySearchRef.current && !paySearchRef.current.contains(e.target as Node)) {
|
|
setPayDropdownOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener("mousedown", handler);
|
|
return () => document.removeEventListener("mousedown", handler);
|
|
}, []);
|
|
|
|
const regsForPayUser = useMemo(() => {
|
|
if (!payUserId) return [];
|
|
return allRegistrations
|
|
.filter((r: any) => String(r.userId || r.user?.id) === String(payUserId))
|
|
.filter((r: any) => (regOutstanding[r.id]?.outstanding ?? 0) > 0.000001)
|
|
.sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
}, [allRegistrations, payUserId, regOutstanding]);
|
|
|
|
const createManualPayment = async () => {
|
|
if (!token) return;
|
|
setPayError(null);
|
|
setPayMessage(null);
|
|
const amt = parseFloat(payAmount || "0");
|
|
if (!amt || amt <= 0) { setPayError("Enter a valid amount"); return; }
|
|
if (!payRegistrationId) { setPayError("Please select a registration"); return; }
|
|
try {
|
|
setPaySubmitting(true);
|
|
await apiFetch<any>("/api/payments", {
|
|
method: "POST",
|
|
authToken: token,
|
|
body: {
|
|
amount: amt,
|
|
method: payMethod,
|
|
userId: payUserId || undefined,
|
|
registrationId: payRegistrationId,
|
|
isDonation: false,
|
|
paidAt: payPaidAtLocal ? new Date(payPaidAtLocal).toISOString() : undefined,
|
|
}
|
|
});
|
|
setPayMessage(`Payment recorded (R ${amt.toFixed(2)}).`);
|
|
setPayAmount("");
|
|
setPayPaidAtLocal("");
|
|
// Refresh registrations so the displayed outstanding balance updates
|
|
try {
|
|
const regs = await apiFetch<any[]>("/api/registrations", { authToken: token });
|
|
setAllRegistrations(Array.isArray(regs) ? regs : []);
|
|
} catch {}
|
|
} catch (e: any) {
|
|
setPayError(e?.message || "Failed to record payment");
|
|
} finally {
|
|
setPaySubmitting(false);
|
|
}
|
|
};
|
|
|
|
// Fuzzy match results (top 6, score threshold 0.45)
|
|
const matchedUsers = useMemo(() => {
|
|
if (userQuery.trim().length < 2) return [];
|
|
return allUsers
|
|
.map(u => ({ u, score: scoreUser(u, userQuery) }))
|
|
.filter(x => x.score >= 0.45)
|
|
.sort((a, b) => b.score - a.score)
|
|
.slice(0, 6)
|
|
.map(x => x.u);
|
|
}, [userQuery, allUsers]);
|
|
|
|
const selectUser = (u: any) => {
|
|
setGuest({ name: u.name || "", email: u.email || "", phoneNumber: u.phoneNumber || "" });
|
|
setUserQuery(u.name || "");
|
|
setDropdownOpen(false);
|
|
};
|
|
|
|
// Close dropdown on outside click
|
|
useEffect(() => {
|
|
const handler = (e: MouseEvent) => {
|
|
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
|
|
setDropdownOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener("mousedown", handler);
|
|
return () => document.removeEventListener("mousedown", handler);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
(async () => {
|
|
try {
|
|
if (!token) return;
|
|
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();
|
|
// Manual registration is rejected server-side for closed (cashed-up) events —
|
|
// don't offer them here even in the rare case one is closed before it ends.
|
|
return !isNaN(t) && t > now && ev.cashupStatus !== 'closed';
|
|
});
|
|
active.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
|
|
setEvents(active);
|
|
} catch (e: any) {
|
|
// ignore
|
|
}
|
|
})();
|
|
}, [token]);
|
|
|
|
useEffect(() => {
|
|
const ev = events.find(e => e.id === selectedEventId);
|
|
if (ev) {
|
|
const opts = ev.options || ev.eventOptions || [];
|
|
setOptions(opts);
|
|
const map: Record<string, number> = {};
|
|
opts.forEach((o: any) => {
|
|
if ((o.variants || []).length > 0) {
|
|
(o.variants as any[]).forEach(v => { map[`${o.id}::${v.id}`] = 0; });
|
|
} else {
|
|
map[o.id] = 0;
|
|
}
|
|
});
|
|
setQuantities(map);
|
|
} else {
|
|
setOptions([]);
|
|
setQuantities({});
|
|
}
|
|
}, [selectedEventId, events]);
|
|
|
|
const totalDue = useMemo(() => {
|
|
return options.reduce((sum, o) => {
|
|
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);
|
|
}, [options, quantities]);
|
|
|
|
const submit = async () => {
|
|
if (!token) return;
|
|
setError(null);
|
|
setMessage(null);
|
|
if (!selectedEventId) { setError("Please select an event."); return; }
|
|
if (!guest.name || (!registerAsGuest && !guest.email)) { setError("Guest name and email are required."); 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 === 0) { setError("Please select at least one ticket option."); return; }
|
|
|
|
try {
|
|
setSubmitting(true);
|
|
const hasEmail = !!guest.email.trim();
|
|
const hasPhone = !!guest.phoneNumber.trim();
|
|
const resolvedPref = hasEmail && hasPhone ? notifPref : hasPhone ? "whatsapp" : "email";
|
|
const res = await apiFetch<any>("/api/registrations/manual", {
|
|
method: "POST",
|
|
authToken: token,
|
|
body: {
|
|
eventId: selectedEventId,
|
|
options: opts,
|
|
user: guest,
|
|
guestOnly: registerAsGuest,
|
|
notificationPreference: resolvedPref,
|
|
}
|
|
});
|
|
setMessage("Manual registration created successfully.");
|
|
|
|
// Pre-load the Record Payment tab with this registration so it's ready to go the
|
|
// moment a supervisor clicks over — the tab itself is never switched to automatically.
|
|
setAllRegistrations(prev => [res, ...prev.filter((r: any) => r.id !== res.id)]);
|
|
setPayUserId(res.userId || res.user?.id || "");
|
|
setPayUserQuery(res.user?.name || guest.name || "");
|
|
setPayRegistrationId(res.id);
|
|
setPayDropdownOpen(false);
|
|
|
|
// Reset guest/ticket fields so the next registration starts from a clean slate
|
|
setGuest({ name: "", email: "", phoneNumber: "" });
|
|
setRegisterAsGuest(false);
|
|
setNotifPref("email");
|
|
setUserQuery("");
|
|
setDropdownOpen(false);
|
|
setQuantities(prev => Object.fromEntries(Object.keys(prev).map(k => [k, 0])));
|
|
} catch (e: any) {
|
|
setError(e?.message || "Failed to create manual registration");
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="max-w-4xl mx-auto w-full p-6">
|
|
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
|
<UserPlus className="w-5 h-5 text-brand-600" />
|
|
</div>
|
|
<h1 className="text-2xl font-semibold text-gray-900">Manual registration</h1>
|
|
</div>
|
|
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
|
|
</div>
|
|
|
|
{!canView && (
|
|
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
|
|
You need supervisor or admin access to use this page.
|
|
</div>
|
|
)}
|
|
|
|
<div className="mb-4 flex items-center gap-2">
|
|
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${tab === 'register' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
|
<input type="radio" name="tab" value="register" className="hidden" checked={tab==='register'} onChange={() => setTab('register')} />
|
|
<UserPlus className="w-4 h-4" />
|
|
Register
|
|
</label>
|
|
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${tab === 'payment' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
|
<input type="radio" name="tab" value="payment" className="hidden" checked={tab==='payment'} onChange={() => setTab('payment')} />
|
|
<CreditCard className="w-4 h-4" />
|
|
Record Payment
|
|
</label>
|
|
</div>
|
|
|
|
{tab === 'register' && (
|
|
<>
|
|
{message && <div className="p-3 mb-3 border rounded bg-emerald-50 text-emerald-800 text-sm">{message}</div>}
|
|
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
|
|
|
|
<div className="grid md:grid-cols-2 gap-6">
|
|
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
|
<div className="text-lg font-semibold mb-3">1) Choose event</div>
|
|
<select className="w-full border rounded px-3 py-2 text-sm" value={selectedEventId} onChange={e => setSelectedEventId(e.target.value)}>
|
|
<option value="">Select an event…</option>
|
|
{events.map(ev => (
|
|
<option key={ev.id} value={ev.id}>{ev.title}</option>
|
|
))}
|
|
</select>
|
|
{selectedEventId && (
|
|
<div className="mt-3 text-xs text-gray-600">
|
|
{(() => {
|
|
const ev = events.find(e => e.id === selectedEventId);
|
|
if (!ev) return null;
|
|
return <>
|
|
<div>{new Date(ev.startDate).toLocaleString()} - {new Date(ev.endDate).toLocaleString()}</div>
|
|
</>;
|
|
})()}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
|
<div className="text-lg font-semibold mb-3">2) Guest details</div>
|
|
|
|
{/* ── User lookup ─────────────────────────────────────────── */}
|
|
<div ref={searchRef} className="relative mb-4">
|
|
<label className="block text-xs font-medium text-gray-500 mb-1">
|
|
Search existing user <span className="font-normal">(name, email or phone)</span>
|
|
</label>
|
|
<input
|
|
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
|
placeholder="Start typing to find a user…"
|
|
value={userQuery}
|
|
autoComplete="off"
|
|
onChange={e => { setUserQuery(e.target.value); setDropdownOpen(true); }}
|
|
onFocus={() => { if (userQuery.length >= 2) setDropdownOpen(true); }}
|
|
/>
|
|
|
|
{dropdownOpen && userQuery.trim().length >= 2 && (
|
|
<div className="absolute z-30 top-full left-0 right-0 mt-1 bg-white border border-gray-200 rounded-xl shadow-lg overflow-hidden">
|
|
{matchedUsers.length > 0 ? (
|
|
<>
|
|
<div className="px-3 py-1.5 text-[11px] text-gray-400 bg-gray-50 border-b">
|
|
{matchedUsers.length} match{matchedUsers.length !== 1 ? "es" : ""} — click to auto-fill
|
|
</div>
|
|
{matchedUsers.map(u => (
|
|
<button
|
|
key={u.id}
|
|
type="button"
|
|
className="w-full text-left px-3 py-2.5 hover:bg-brand-50 border-b border-gray-100 last:border-b-0 transition-colors"
|
|
onClick={() => selectUser(u)}
|
|
>
|
|
<div className="text-sm font-medium text-gray-900">{u.name}</div>
|
|
<div className="text-xs text-gray-500 mt-0.5 flex gap-2 flex-wrap">
|
|
{u.email && <span>{u.email}</span>}
|
|
{u.phoneNumber && <span>· {u.phoneNumber}</span>}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</>
|
|
) : (
|
|
<div className="px-3 py-3 text-sm text-gray-500 italic">
|
|
No matching users found — fill in details below manually.
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="border-t pt-3 mb-3">
|
|
<div className="flex items-center gap-2">
|
|
<input id="registerAsGuest" type="checkbox" checked={registerAsGuest} onChange={e => setRegisterAsGuest(e.target.checked)} />
|
|
<label htmlFor="registerAsGuest" className="text-sm text-gray-700">Guest (do not link to an existing account)</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<input
|
|
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
|
placeholder="Full name"
|
|
value={guest.name}
|
|
onChange={e => setGuest({ ...guest, name: e.target.value })}
|
|
/>
|
|
<input
|
|
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
|
placeholder={registerAsGuest ? "Email (optional for guest)" : "Email"}
|
|
type="email"
|
|
value={guest.email}
|
|
onChange={e => setGuest({ ...guest, email: e.target.value })}
|
|
required={!registerAsGuest}
|
|
/>
|
|
<input
|
|
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
|
placeholder="Phone (optional)"
|
|
value={guest.phoneNumber}
|
|
onChange={e => {
|
|
const v = e.target.value;
|
|
setGuest({ ...guest, phoneNumber: v });
|
|
if (v.trim() && !guest.email.trim()) setNotifPref("whatsapp");
|
|
else if (!v.trim() && guest.email.trim()) setNotifPref("email");
|
|
}}
|
|
/>
|
|
|
|
{/* Preference selector */}
|
|
{(() => {
|
|
const hasEmail = !!guest.email.trim();
|
|
const hasPhone = !!guest.phoneNumber.trim();
|
|
if (!hasEmail && !hasPhone) return null;
|
|
if (hasEmail && !hasPhone) return (
|
|
<p className="text-xs text-gray-500">Tickets will be sent via <strong>email</strong>.</p>
|
|
);
|
|
if (hasPhone && !hasEmail) return (
|
|
<p className="text-xs text-gray-500">Tickets will be sent via <strong>WhatsApp</strong>.</p>
|
|
);
|
|
return (
|
|
<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 border-green-600"
|
|
: p === "both" ? "bg-brand-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>
|
|
);
|
|
})()}
|
|
|
|
{guest.name && (
|
|
<button
|
|
type="button"
|
|
onClick={() => { setGuest({ name: "", email: "", phoneNumber: "" }); setUserQuery(""); setNotifPref("email"); }}
|
|
className="text-xs text-gray-400 hover:text-gray-600 text-left"
|
|
>
|
|
✕ Clear
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-6 border rounded-xl p-4 bg-white shadow-sm">
|
|
<div className="text-lg font-semibold mb-3">3) Select ticket options</div>
|
|
{options.length === 0 ? (
|
|
<div className="text-sm text-gray-500">Select an event to view options.</div>
|
|
) : (
|
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
{options.map(opt => {
|
|
const hasVariants = (opt.variants || []).length > 0;
|
|
if (hasVariants) {
|
|
return (
|
|
<div key={opt.id} className="border rounded overflow-hidden col-span-full sm:col-span-1">
|
|
<div className="px-3 py-2 bg-gray-50 border-b text-sm font-medium text-gray-800">
|
|
{opt.name}{opt.isMainTicket ? <span className="ml-1.5 text-xs text-brand-600 font-normal">• Main</span> : null}
|
|
</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}`;
|
|
return (
|
|
<div key={v.id} className="flex items-center justify-between px-3 py-2 border-b last:border-b-0">
|
|
<div>
|
|
<div className="text-sm">{v.name}</div>
|
|
<div className="text-xs text-gray-500">
|
|
{fmtPrice(unit)}
|
|
{unit < basePrice && basePrice > 0 && <span className="ml-1 text-green-600">(early bird, was {fmtPrice(basePrice)})</span>}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<label className="text-xs text-gray-500">Qty</label>
|
|
<input
|
|
type="number" min={0}
|
|
className="w-16 border rounded px-2 py-1 text-sm"
|
|
value={quantities[key] || 0}
|
|
onChange={e => setQuantities(q => ({ ...q, [key]: Math.max(0, parseInt(e.target.value || '0')) }))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
const unit = effectiveOptionUnit(opt);
|
|
return (
|
|
<div key={opt.id} className="border rounded p-3">
|
|
<div className="font-medium text-sm">{opt.name}</div>
|
|
<div className="text-xs text-gray-500">
|
|
{fmtPrice(unit)}
|
|
{unit < opt.price && opt.price > 0 && <span className="ml-1 text-green-600">(early bird, was {fmtPrice(opt.price)})</span>}
|
|
{opt.isMainTicket ? " • Main" : ""}
|
|
</div>
|
|
<div className="flex items-center gap-2 mt-2">
|
|
<label className="text-xs text-gray-600">Qty</label>
|
|
<input
|
|
type="number" min={0}
|
|
className="w-20 border rounded px-2 py-1 text-sm"
|
|
value={quantities[opt.id] || 0}
|
|
onChange={e => setQuantities(q => ({ ...q, [opt.id]: Math.max(0, parseInt(e.target.value || '0')) }))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between mt-6">
|
|
<div className="text-sm">Total due: <span className="font-semibold">R {totalDue.toFixed(2)}</span></div>
|
|
<button disabled={submitting} onClick={submit} className="px-4 py-2 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 shadow-sm">{submitting ? 'Creating…' : 'Create registration'}</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{tab === 'payment' && (
|
|
<div className="max-w-xl">
|
|
{payMessage && <div className="p-3 mb-3 border rounded bg-emerald-50 text-emerald-800 text-sm">{payMessage}</div>}
|
|
{payError && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{payError}</div>}
|
|
|
|
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
|
<div className="text-lg font-semibold mb-3">Record a payment</div>
|
|
<div className="grid gap-3">
|
|
<div>
|
|
<label className="block text-xs text-gray-600 mb-1">Amount</label>
|
|
<input className="w-full border rounded px-3 py-2 text-sm" type="number" step="0.01" value={payAmount} onChange={e => setPayAmount(e.target.value)} />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-gray-600 mb-1">Method</label>
|
|
<select className="w-full border rounded px-3 py-2 text-sm" value={payMethod} onChange={e => setPayMethod(e.target.value)}>
|
|
<option value="cash">Cash</option>
|
|
<option value="card">Card</option>
|
|
<option value="eft">EFT</option>
|
|
<option value="voucher">Voucher</option>
|
|
</select>
|
|
</div>
|
|
<div ref={paySearchRef} className="relative">
|
|
<label className="block text-xs text-gray-600 mb-1">User</label>
|
|
<input
|
|
className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
|
placeholder="Search by name, email or phone…"
|
|
value={payUserQuery}
|
|
autoComplete="off"
|
|
onChange={e => { setPayUserQuery(e.target.value); setPayDropdownOpen(true); if (!e.target.value) { setPayUserId(""); setPayRegistrationId(""); } }}
|
|
onFocus={() => { if (payUserQuery.length >= 2) setPayDropdownOpen(true); }}
|
|
/>
|
|
{payDropdownOpen && payUserQuery.trim().length >= 2 && (
|
|
<div className="absolute z-30 top-full left-0 right-0 mt-1 bg-white border border-gray-200 rounded-xl shadow-lg overflow-hidden">
|
|
{payMatchedUsers.length > 0 ? (
|
|
<>
|
|
<div className="px-3 py-1.5 text-[11px] text-gray-400 bg-gray-50 border-b">
|
|
{payMatchedUsers.length} match{payMatchedUsers.length !== 1 ? "es" : ""}
|
|
</div>
|
|
{payMatchedUsers.map(u => (
|
|
<button
|
|
key={u.id}
|
|
type="button"
|
|
className="w-full text-left px-3 py-2.5 hover:bg-brand-50 border-b border-gray-100 last:border-b-0 transition-colors"
|
|
onClick={() => selectPayUser(u)}
|
|
>
|
|
<div className="text-sm font-medium text-gray-900">{u.name}</div>
|
|
<div className="text-xs text-gray-500 mt-0.5 flex gap-2 flex-wrap">
|
|
{u.email && <span>{u.email}</span>}
|
|
{u.phoneNumber && <span>· {u.phoneNumber}</span>}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</>
|
|
) : (
|
|
<div className="px-3 py-3 text-sm text-gray-500 italic">No matching users found.</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-gray-600 mb-1">Registration</label>
|
|
<select className="w-full max-w-full border rounded px-3 py-2 text-sm truncate" value={payRegistrationId} onChange={e => setPayRegistrationId(e.target.value)} disabled={!payUserId}>
|
|
<option value="">Select registration…</option>
|
|
{regsForPayUser.map((r: any) => {
|
|
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>;
|
|
})}
|
|
</select>
|
|
{!payUserId && <div className="text-xs text-gray-500 mt-1">Search for a user above to see their registrations.</div>}
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-gray-600 mb-1">Paid at (optional)</label>
|
|
<input className="w-full border rounded px-3 py-2 text-sm" type="datetime-local" value={payPaidAtLocal} onChange={e => setPayPaidAtLocal(e.target.value)} max={new Date().toISOString().slice(0,16)} />
|
|
<div className="text-[10px] text-gray-500 mt-1">Leave blank to use current time</div>
|
|
</div>
|
|
</div>
|
|
<div className="mt-3">
|
|
<button disabled={paySubmitting} onClick={createManualPayment} className="w-full px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 shadow-sm">{paySubmitting ? 'Recording…' : 'Record payment'}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|