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>
211 lines
10 KiB
TypeScript
211 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import React, { useEffect, useState } from "react";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { useRouter } from "next/navigation";
|
|
import { apiFetch } from "@/lib/api";
|
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
|
import { UserPlus } from "lucide-react";
|
|
|
|
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null };
|
|
|
|
export default function ManualRegistrationPage() {
|
|
const { user, token, loading } = useAuth();
|
|
const router = useRouter();
|
|
const [eventId, setEventId] = useState("");
|
|
const [optionId, setOptionId] = useState("");
|
|
const [quantity, setQuantity] = useState(1);
|
|
const [name, setName] = useState("");
|
|
const [email, setEmail] = useState("");
|
|
const [phoneNumber, setPhoneNumber] = useState("");
|
|
const [registerAsGuest, setRegisterAsGuest] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useDismissingState<string | null>(null);
|
|
const [createdReg, setCreatedReg] = useState<any | null>(null);
|
|
const [form, setForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
|
|
const [formsData, setFormsData] = useState<Record<number, Record<string, string>>>({});
|
|
|
|
useEffect(() => {
|
|
if (loading) return;
|
|
if (!user) router.replace("/login");
|
|
}, [user, loading, router]);
|
|
|
|
async function submit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
if (!token) return;
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
const res = await apiFetch<any>("/api/registrations/manual", {
|
|
method: "POST",
|
|
authToken: token,
|
|
body: {
|
|
eventId,
|
|
options: [{ eventOptionId: optionId, quantity }],
|
|
user: { name, ...(email ? { email } : {}), ...(phoneNumber ? { phoneNumber } : {}) },
|
|
guestOnly: registerAsGuest,
|
|
},
|
|
});
|
|
setCreatedReg(res);
|
|
// Load form definition for this event (if any)
|
|
try {
|
|
const ev = await apiFetch<any>(`/api/events/${encodeURIComponent(eventId)}`);
|
|
if (ev?.form) setForm(ev.form);
|
|
} catch {}
|
|
alert("Manual registration created");
|
|
} catch (e: any) {
|
|
setError(e?.message || "Failed to create manual registration");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-xl">
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<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-xl font-semibold text-gray-900">Manual Registration</h1>
|
|
</div>
|
|
<form onSubmit={submit} className="space-y-3">
|
|
<div>
|
|
<label className="block text-sm font-medium">Event ID</label>
|
|
<input className="w-full border rounded px-3 py-2" value={eventId} onChange={(e) => setEventId(e.target.value)} required />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-sm font-medium">Option ID</label>
|
|
<input className="w-full border rounded px-3 py-2" value={optionId} onChange={(e) => setOptionId(e.target.value)} required />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium">Quantity</label>
|
|
<input type="number" min={1} className="w-full border rounded px-3 py-2" value={quantity} onChange={(e) => setQuantity(parseInt(e.target.value || "1", 10))} required />
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-sm font-medium">Name</label>
|
|
<input className="w-full border rounded px-3 py-2" value={name} onChange={(e) => setName(e.target.value)} required />
|
|
</div>
|
|
<div>
|
|
<div className="flex items-center justify-between">
|
|
<label className="block text-sm font-medium">Email</label>
|
|
<label className="text-xs flex items-center gap-2"><input type="checkbox" checked={registerAsGuest} onChange={e=>setRegisterAsGuest(e.target.checked)} /> Guest (no account)</label>
|
|
</div>
|
|
<input type="email" className="w-full border rounded px-3 py-2" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="email@example.com" />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium">Cell Number</label>
|
|
<input type="tel" className="w-full border rounded px-3 py-2" value={phoneNumber} onChange={(e) => setPhoneNumber(e.target.value)} placeholder="+27…" />
|
|
</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 automatically.</p>
|
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
|
<button type="submit" disabled={busy} className="bg-brand-600 hover:bg-brand-700 text-white rounded px-4 py-2 disabled:opacity-60">
|
|
{busy ? "Submitting..." : "Create"}
|
|
</button>
|
|
</form>
|
|
|
|
{createdReg && form && Array.isArray(form.fields) && (
|
|
<AttendeeFormsSection registration={createdReg} form={form} formsData={formsData} setFormsData={setFormsData} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AttendeeFormsSection({ registration, form, formsData, setFormsData }: { registration: any; form: { isRequired: boolean; fields: FormField[] }; formsData: Record<number, Record<string,string>>; setFormsData: any; }) {
|
|
const { token } = useAuth();
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [error, setError] = useDismissingState<string | null>(null);
|
|
const [info, setInfo] = useDismissingState<string | null>(null);
|
|
const mainTickets = (registration?.registrationOptions || []).filter((o: any) => o?.eventOption?.isMainTicket).reduce((s: number, o: any) => s + (o.quantity || 0), 0);
|
|
const count = Math.max(0, mainTickets);
|
|
|
|
const canSubmit = React.useMemo(() => {
|
|
if (!form || count <= 0) return false;
|
|
const reqFields = (form.fields || [])
|
|
.filter(f => !!f.isRequired && f.type !== 'statement' && f.type !== 'paragraph')
|
|
.map(f => f.id);
|
|
for (let i = 0; i < count; i++) {
|
|
const data = formsData[i] || {};
|
|
for (const fid of reqFields) {
|
|
const v = data[fid];
|
|
if (v === undefined || v === null || String(v).trim() === '') {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
}, [form, formsData, count]);
|
|
|
|
const update = (idx: number, fieldId: string, value: string) => {
|
|
setFormsData((prev: any) => ({ ...prev, [idx]: { ...(prev[idx]||{}), [fieldId]: value } }));
|
|
};
|
|
|
|
const submit = async () => {
|
|
if (!token) return;
|
|
try {
|
|
setSubmitting(true);
|
|
setError(null); setInfo(null);
|
|
const payload = [] as any[];
|
|
for (let i = 0; i < count; i++) payload.push({ answers: formsData[i] || {} });
|
|
await apiFetch(`/api/registrations/${encodeURIComponent(registration.id)}/forms/responses`, {
|
|
method: 'POST', authToken: token, body: { responses: payload }
|
|
});
|
|
setInfo('Attendee forms submitted successfully.');
|
|
} catch (e: any) {
|
|
setError(e?.message || 'Failed to submit forms');
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
if (!form || !Array.isArray(form.fields) || count === 0) return null;
|
|
return (
|
|
<div className="mt-6 border rounded p-3 bg-gray-50">
|
|
<div className="text-sm font-medium mb-2">Attendee forms for this registration</div>
|
|
{error && <div className="text-xs text-red-600 mb-2">{error}</div>}
|
|
{info && <div className="text-xs text-emerald-700 mb-2">{info}</div>}
|
|
<div className="space-y-4">
|
|
{Array.from({ length: count }, (_, idx) => (
|
|
<div key={idx} className="bg-white border rounded p-3">
|
|
<div className="font-medium mb-2">Attendee {idx + 1}</div>
|
|
{form.fields.map((f) => (
|
|
<div key={f.id} className="mb-2">
|
|
{f.type === 'statement' ? (
|
|
<div className="text-sm text-gray-700 whitespace-pre-line">{f.label}</div>
|
|
) : f.type === 'paragraph' ? (
|
|
<div className="text-sm text-gray-700">
|
|
{f.label && <div className="font-medium mb-1 whitespace-pre-line">{f.label}</div>}
|
|
{f.helpText && <div className="whitespace-pre-line">{f.helpText}</div>}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<label className="block text-xs text-gray-600 mb-1">{f.label}{f.isRequired ? ' *' : ''}</label>
|
|
{f.type === 'yes_no' ? (
|
|
<select className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => update(idx, f.id, e.target.value)}>
|
|
<option value="">Select</option>
|
|
<option value="yes">Yes</option>
|
|
<option value="no">No</option>
|
|
</select>
|
|
) : f.type === 'date' ? (
|
|
<input type="date" className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => update(idx, f.id, e.target.value)} />
|
|
) : f.type === 'numeric' ? (
|
|
<input type="number" className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => update(idx, f.id, e.target.value)} />
|
|
) : (
|
|
<input type="text" className="border rounded px-2 py-1 text-sm w-full" value={formsData[idx]?.[f.id] || ''} onChange={e => update(idx, f.id, e.target.value)} />
|
|
)}
|
|
{f.helpText && <div className="text-xs text-gray-500 mt-1">{f.helpText}</div>}
|
|
</>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
<button className="mt-3 px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50" disabled={submitting || !canSubmit} onClick={submit}>{submitting ? 'Submitting…' : 'Submit forms'}</button>
|
|
</div>
|
|
);
|
|
}
|