Files
hope-events/frontend/src/app/dashboard/supervisor/manual/page.tsx
T
joshuaandClaude Sonnet 5 8e6cb542d9 Full site redesign, help system, and dashboard stats fixes
Multi-phase visual facelift (design tokens, dashboards, sidebar/navbar
shell, per-page help guides, and a layout/content pass across every
remaining page) plus backend fixes to the dashboard KPI stats:

- Admin/Supervisor dashboard KPIs (revenue, donations, registrations,
  tickets sold) now use a rolling trailing-month window (today back one
  calendar month, e.g. 9 May - 8 June if today is 8 June) instead of
  calendar month-to-date, which under-counted for most of the month.
  The comparison window shifts the same way, so like is still compared
  with like.
- Reports deep-links from those stat tiles now match the same window
  (range=trailing_month, replacing range=this_month).
- Design tokens (brand-* Tailwind scale + shadcn CSS variables), a
  site-wide contextual help button, fixed dashboard sidebar/navbar,
  Admin/Supervisor/Staff/User dashboard rebuilds backed by a new
  GET /api/stats/overview endpoint, a dedicated Contact page, Site
  Settings restyle with WhatsApp config folded in, and an Account
  activity feed backed by a new SecurityEvent model.
- Every remaining page (home, events, registration flow, auth, legal,
  payment results, and every Admin/Supervisor/Staff/User tool page)
  restyled onto the same design tokens, several with real layout
  upgrades (home hero, events list/detail, donate page, auth pages).
- 20+ new dedicated help guides so the whole site has page-specific
  help content instead of falling back to a generic guide.
- Assorted fixes surfaced along the way: donation-leg double-counting
  in payment stats, donations not counting toward revenue, refund
  netting in per-method report breakdowns, and donation
  over-allocation after a refund.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 15:00:10 +02:00

686 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 } 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={`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')} />
Register
</label>
<label className={`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')} />
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>
);
}