Type supervisor forms, manual registration and events pages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 11:10:01 +02:00
co-authored by Claude Opus 5.5
parent b560d0d340
commit 0a8788afff
4 changed files with 204 additions and 163 deletions
@@ -1,6 +1,6 @@
"use client";
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
@@ -11,6 +11,7 @@ import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { Calendar } from "lucide-react";
import { ChoicesEditor } from "@/components/shared/FormFieldExtras";
import { errorMessage } from "@/lib/utils";
import type { Event, EventAttachment, EventFormField, EventOption, Paginated, Section } from "@/types";
// ─── helpers ────────────────────────────────────────────────────────────────
@@ -46,7 +47,26 @@ function DTInput({ label, value, onChange, hint, required }: { label: string; va
// ─── types ───────────────────────────────────────────────────────────────────
type FormFieldType = "yes_no" | "text" | "date" | "numeric" | "statement" | "paragraph" | "rating" | "multiple_choice";
type EventFormDef = { isRequired: boolean; fields: { id?: string; type: FormFieldType; label: string; isRequired?: boolean; order?: number; helpText?: string | null; options?: any }[] };
type EventFormDef = { isRequired: boolean; fields: { id?: string; type: FormFieldType; label: string; isRequired?: boolean; order?: number; helpText?: string | null; options?: unknown }[] };
type FieldDef = EventFormDef["fields"][number];
// What PUT /api/events/options/:id takes as earlyBirdTiers (variantId null = option-level).
type TierPayload = { id?: string; deadline: string; price: number; stockLimit: number; order: number; variantId: string | null };
// GET /api/feedback/event/:id — the event's post-event feedback form, if it has one.
type FeedbackFormResponse = {
fields?: EventFormField[];
sendAt?: string | null;
audience?: string;
anonymous?: boolean;
intro?: string | null;
sentAt: string | null;
invited: number;
responses: number;
};
const toFieldDefs = (fields: EventFormField[] | undefined): FieldDef[] =>
(fields || []).map((x) => ({ id: x.id, type: x.type, label: x.label, isRequired: !!x.isRequired, helpText: x.helpText || "", options: x.options || undefined }));
type Tier = { id?: string; deadline: string; price: string; stockLimit: string; order?: number };
@@ -90,8 +110,9 @@ function UploadImageButton({ onUploaded, label = "Upload image" }: { onUploaded:
try {
const fd = new FormData();
fd.append("image", file);
const res: any = await apiFetch("/api/uploads/event-image", { method: "POST", body: fd, authToken: token });
if (res?.url || res?.path) onUploaded(res.url || res.path);
const res = await apiFetch<{ url?: string; path?: string }>("/api/uploads/event-image", { method: "POST", body: fd, authToken: token });
const uploaded = res?.url || res?.path;
if (uploaded) onUploaded(uploaded);
} finally { setUploading(false); if (ref.current) ref.current.value = ""; }
}} />
<button type="button" onClick={() => ref.current?.click()} disabled={uploading}
@@ -111,7 +132,7 @@ function FormBuilder({ value, onChange, title = "Registration Form Fields", gate
}) {
const fields = value.fields || [];
const add = (type: FormFieldType) => onChange({ ...value, fields: [...fields, { type, label: "", isRequired: false, helpText: "" }] });
const upd = (idx: number, patch: any) => { const c = fields.slice(); c[idx] = { ...c[idx], ...patch }; onChange({ ...value, fields: c }); };
const upd = (idx: number, patch: Partial<FieldDef>) => { const c = fields.slice(); c[idx] = { ...c[idx], ...patch }; onChange({ ...value, fields: c }); };
const rem = (idx: number) => { const c = fields.slice(); c.splice(idx, 1); onChange({ ...value, fields: c }); };
const mov = (idx: number, dir: -1 | 1) => {
const c = fields.slice(); const t = idx + dir;
@@ -145,7 +166,7 @@ function FormBuilder({ value, onChange, title = "Registration Form Fields", gate
<button type="button" className="text-xs px-2 py-0.5 rounded bg-red-50 text-red-700 border border-red-200" onClick={() => rem(idx)}>Remove</button>
</div>
<div className="flex flex-wrap gap-2 mb-1.5">
<select className="border rounded px-2 py-1 text-xs" value={f.type} onChange={e => upd(idx, { type: e.target.value })}>
<select className="border rounded px-2 py-1 text-xs" value={f.type} onChange={e => upd(idx, { type: e.target.value as FormFieldType })}>
<option value="text">Text</option><option value="numeric">Number</option><option value="date">Date</option>
<option value="yes_no">Yes/No</option><option value="rating">Rating (1–5 stars)</option><option value="multiple_choice">Multiple choice</option>
<option value="statement">Statement</option><option value="paragraph">Heading+para</option>
@@ -454,24 +475,24 @@ function SectionsDraftEditor({ sections, options, onChange }: {
/** Edit-mode: live sections manager (DB-backed) */
function SectionsManager({ eventId }: { eventId: string }) {
const { token } = useAuth();
const [sections, setSections] = useState<any[]>([]);
const [eventOptions, setEventOptions] = useState<any[]>([]);
const [sections, setSections] = useState<Section[]>([]);
const [eventOptions, setEventOptions] = useState<EventOption[]>([]);
const [loading, setLoading] = useState(false);
const [newName, setNewName] = useState("");
const [newOptionIds, setNewOptionIds] = useState<string[]>([]);
const load = async () => {
const load = useCallback(async () => {
setLoading(true);
try {
const [secList, evFull] = await Promise.all([
apiFetch<any[]>("/api/sections", { authToken: token || undefined }),
apiFetch<any>(`/api/events/${eventId}`)
apiFetch<Section[]>("/api/sections", { authToken: token || undefined }),
apiFetch<Event>(`/api/events/${eventId}`)
]);
setSections((secList || []).filter(s => s.eventId === eventId));
setEventOptions(evFull?.eventOptions || []);
} catch {} finally { setLoading(false); }
};
useEffect(() => { if (eventId) load(); }, [eventId]);
}, [eventId, token]);
useEffect(() => { if (eventId) load(); }, [eventId, load]);
const create = async () => {
if (!newName.trim() || !token) return;
@@ -516,14 +537,19 @@ function SectionsManager({ eventId }: { eventId: string }) {
);
}
function SectionRow({ sec, options, onDelete, onUpdate }: any) {
function SectionRow({ sec, options, onDelete, onUpdate }: {
sec: Section;
options: EventOption[];
onDelete: () => void;
onUpdate: (id: string, name: string, optionIds: string[]) => void;
}) {
const [name, setName] = useState(sec.name || "");
const [optIds, setOptIds] = useState<string[]>((sec.allowedOptions || []).map((o: any) => o.eventOptionId));
const [optIds, setOptIds] = useState<string[]>((sec.allowedOptions || []).map((o) => o.eventOptionId));
return (
<div className="border rounded p-3 bg-white">
<input className="border rounded px-2 py-1 text-sm w-full mb-2" value={name} onChange={e => setName(e.target.value)} />
<div className="flex flex-wrap gap-2 mb-2">
{options.map((opt: any) => (
{options.map((opt) => (
<label key={opt.id} className="text-xs flex items-center gap-1 cursor-pointer">
<input type="checkbox" checked={optIds.includes(opt.id)}
onChange={e => setOptIds(e.target.checked ? [...optIds, opt.id] : optIds.filter(id => id !== opt.id))} />
@@ -543,15 +569,15 @@ function SectionRow({ sec, options, onDelete, onUpdate }: any) {
function AttachmentsManager({ eventId }: { eventId: string }) {
const { token } = useAuth();
const [items, setItems] = useState<any[]>([]);
const [items, setItems] = useState<EventAttachment[]>([]);
const [uploading, setUploading] = useState(false);
const ref = useRef<HTMLInputElement | null>(null);
const load = async () => {
const res = await apiFetch<any[]>(`/api/events/${eventId}/attachments`);
const load = useCallback(async () => {
const res = await apiFetch<EventAttachment[]>(`/api/events/${eventId}/attachments`);
setItems(Array.isArray(res) ? res : []);
};
useEffect(() => { load(); }, [eventId]);
}, [eventId]);
useEffect(() => { load(); }, [load]);
const upload: React.ChangeEventHandler<HTMLInputElement> = async e => {
const file = e.target.files?.[0];
@@ -647,7 +673,7 @@ function NotifyRecipientsPicker({ selected, onChange, disabled }: { selected: No
setSearching(true);
const t = setTimeout(async () => {
try {
const res = await apiFetch<any>(`/api/users?search=${encodeURIComponent(search.trim())}&limit=10`, { authToken: token || undefined });
const res = await apiFetch<Paginated<NotifyUser>>(`/api/users?search=${encodeURIComponent(search.trim())}&limit=10`, { authToken: token || undefined });
if (!cancelled) setResults(Array.isArray(res?.data) ? res.data : []);
} catch {
if (!cancelled) setResults([]);
@@ -730,7 +756,7 @@ function NotifyRecipientsManager({ eventId, creator, initialRecipients }: { even
(async () => {
setLoading(true);
try {
const res = await apiFetch<any>(`/api/events/${eventId}/notify-recipients`, { authToken: token || undefined });
const res = await apiFetch<NotifyUser[]>(`/api/events/${eventId}/notify-recipients`, { authToken: token || undefined });
if (!cancelled) setSelected(Array.isArray(res) ? res : []);
} catch (e) {
if (!cancelled) setError(errorMessage(e) || "Failed to load notification recipients");
@@ -815,7 +841,7 @@ const blankOptions = (): OptionDraft[] => [
interface EventModalProps {
mode: "create" | "edit";
event?: any;
event?: Event | null;
onClose: () => void;
onSuccess: () => void;
}
@@ -852,8 +878,8 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
// ── options (with per-variant tiers) ──
const [options, setOptions] = useState<OptionDraft[]>(() => {
if (!ev?.eventOptions?.length) return blankOptions();
return ev.eventOptions.map((o: any) => {
const allTiers: any[] = o.earlyBirdTiers || [];
return ev.eventOptions.map((o) => {
const allTiers = o.earlyBirdTiers || [];
return {
id: o.id,
name: o.name,
@@ -863,17 +889,17 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
staffPrice: o.staffPrice !== null && o.staffPrice !== undefined ? String(o.staffPrice) : "",
// Option-level tiers only (no variantId)
earlyBirdTiers: allTiers
.filter((t: any) => !t.variantId)
.map((t: any) => ({ id: t.id, deadline: toLocalDT(t.deadline), price: String(t.price ?? ""), stockLimit: String(t.stockLimit ?? "0") })),
variants: (o.variants || []).map((v: any) => ({
.filter((t) => !t.variantId)
.map((t) => ({ id: t.id, deadline: toLocalDT(t.deadline), price: String(t.price ?? ""), stockLimit: String(t.stockLimit ?? "0") })),
variants: (o.variants || []).map((v) => ({
id: v.id, name: v.name,
price: v.price !== null && v.price !== undefined ? String(v.price) : "",
stockLimit: String(v.stockLimit ?? "0"),
staffPrice: v.staffPrice !== null && v.staffPrice !== undefined ? String(v.staffPrice) : "",
// Variant-level tiers — filter by variantId
earlyBirdTiers: allTiers
.filter((t: any) => t.variantId === v.id)
.map((t: any) => ({ id: t.id, deadline: toLocalDT(t.deadline), price: String(t.price ?? ""), stockLimit: String(t.stockLimit ?? "0") })),
.filter((t) => t.variantId === v.id)
.map((t) => ({ id: t.id, deadline: toLocalDT(t.deadline), price: String(t.price ?? ""), stockLimit: String(t.stockLimit ?? "0") })),
})),
};
});
@@ -893,9 +919,9 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
const formLoadedRef = useRef(false);
useEffect(() => {
if (mode !== "edit" || !ev?.id || formLoadedRef.current) return;
apiFetch<any>(`/api/events/${ev.id}`).then(full => {
apiFetch<Event>(`/api/events/${ev.id}`).then(full => {
const f = full?.form;
if (f) setFormDef({ isRequired: !!f.isRequired, fields: (f.fields || []).map((x: any) => ({ id: x.id, type: x.type, label: x.label, isRequired: !!x.isRequired, helpText: x.helpText || "", options: x.options || undefined })) });
if (f) setFormDef({ isRequired: !!f.isRequired, fields: toFieldDefs(f.fields) });
}).catch(() => {}).finally(() => { formLoadedRef.current = true; });
}, [mode, ev?.id]);
@@ -906,10 +932,10 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
const [fbMeta, setFbMeta] = useState<{ sentAt: string | null; invited: number; responses: number } | null>(null);
useEffect(() => {
if (mode !== "edit" || !ev?.id || !token) return;
apiFetch<any>(`/api/feedback/event/${ev.id}`, { authToken: token }).then(f => {
apiFetch<FeedbackFormResponse | null>(`/api/feedback/event/${ev.id}`, { authToken: token }).then(f => {
if (!f) return;
setFbEnabled(true);
setFbDef({ isRequired: false, fields: (f.fields || []).map((x: any) => ({ id: x.id, type: x.type, label: x.label, isRequired: !!x.isRequired, helpText: x.helpText || "", options: x.options || undefined })) });
setFbDef({ isRequired: false, fields: toFieldDefs(f.fields) });
setFbSettings({ sendAt: toLocalDT(f.sendAt), audience: f.audience || "checked_in", anonymous: !!f.anonymous, intro: f.intro || "" });
setFbMeta({ sentAt: f.sentAt, invited: f.invited, responses: f.responses });
}).catch(() => {});
@@ -992,7 +1018,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
if (mode === "edit") {
for (const opt of options) {
// Build flat tier array: option-level + all variant tiers
const allTiers: any[] = [
const allTiers: TierPayload[] = [
...opt.earlyBirdTiers
.filter(t => t.deadline && t.price !== "")
.map((t, i) => ({ id: t.id, deadline: new Date(t.deadline).toISOString(), price: parseFloat(t.price), stockLimit: parseInt(t.stockLimit || "0"), order: i, variantId: null })),
@@ -1007,16 +1033,16 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
if (opt.id) {
// Update option + variants first to get/confirm variant IDs
const updated: any = await apiFetch(`/api/events/options/${opt.id}`, {
const updated = await apiFetch<EventOption>(`/api/events/options/${opt.id}`, {
method: "PUT", authToken: token || undefined,
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, variants: variantsPayload }
});
// Build variant tiers with confirmed IDs from response
const serverVariants: any[] = updated?.variants || [];
const serverVariants = updated?.variants || [];
for (let vi = 0; vi < opt.variants.length; vi++) {
const v = opt.variants[vi];
const serverVariant = serverVariants[vi] || serverVariants.find((sv: any) => sv.name === v.name);
const serverVariant = serverVariants[vi] || serverVariants.find((sv) => sv.name === v.name);
if (!serverVariant) continue;
for (let ti = 0; ti < v.earlyBirdTiers.length; ti++) {
const t = v.earlyBirdTiers[ti];
@@ -1039,19 +1065,19 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
});
} else {
// New option added during edit
const created: any = await apiFetch(`/api/events/${eventId}/options`, {
const created = await apiFetch<EventOption>(`/api/events/${eventId}/options`, {
method: "POST", authToken: token || undefined,
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null }
});
if (created?.id) {
const updated: any = await apiFetch(`/api/events/options/${created.id}`, {
const updated = await apiFetch<EventOption>(`/api/events/options/${created.id}`, {
method: "PUT", authToken: token || undefined,
body: { variants: variantsPayload }
});
const serverVariants: any[] = updated?.variants || [];
const serverVariants = updated?.variants || [];
for (let vi = 0; vi < opt.variants.length; vi++) {
const v = opt.variants[vi];
const sv = serverVariants[vi] || serverVariants.find((x: any) => x.name === v.name);
const sv = serverVariants[vi] || serverVariants.find((x) => x.name === v.name);
if (!sv) continue;
for (let ti = 0; ti < v.earlyBirdTiers.length; ti++) {
const t = v.earlyBirdTiers[ti];
@@ -1069,9 +1095,9 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
}
} else {
// Create mode — backend auto-creates a "Main Ticket" option; replace/create from draft
let createdEvent: any = null;
try { createdEvent = await apiFetch(`/api/events/${eventId}`); } catch {}
const existingMain = (createdEvent?.eventOptions || []).find((o: any) => !!o.isMainTicket);
let createdEvent: Event | null = null;
try { createdEvent = await apiFetch<Event>(`/api/events/${eventId}`); } catch {}
const existingMain = (createdEvent?.eventOptions || []).find((o) => !!o.isMainTicket);
const uiMainIdx = options.findIndex(o => o.isMainTicket);
for (let i = 0; i < options.length; i++) {
@@ -1092,7 +1118,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
});
optId = existingMain.id;
} else {
const created: any = await apiFetch(`/api/events/${eventId}/options`, {
const created = await apiFetch<EventOption>(`/api/events/${eventId}/options`, {
method: "POST", authToken: token || undefined,
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null }
});
@@ -1102,18 +1128,18 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
if (!optId) continue;
// Save variants, then build tiers with resolved IDs
const allTiers: any[] = opt.earlyBirdTiers
const allTiers: TierPayload[] = opt.earlyBirdTiers
.filter(t => t.deadline && t.price !== "")
.map((t, ti) => ({ deadline: new Date(t.deadline).toISOString(), price: parseFloat(t.price), stockLimit: parseInt(t.stockLimit || "0"), order: ti, variantId: null }));
if (variantsPayload.length > 0 || opt.variants.some(v => v.earlyBirdTiers.length > 0)) {
const updated: any = await apiFetch(`/api/events/options/${optId}`, {
const updated = await apiFetch<EventOption>(`/api/events/options/${optId}`, {
method: "PUT", authToken: token || undefined, body: { variants: variantsPayload }
});
const serverVariants: any[] = updated?.variants || [];
const serverVariants = updated?.variants || [];
for (let vi = 0; vi < opt.variants.length; vi++) {
const v = opt.variants[vi];
const sv = serverVariants[vi] || serverVariants.find((x: any) => x.name === v.name);
const sv = serverVariants[vi] || serverVariants.find((x) => x.name === v.name);
if (!sv) continue;
for (let ti = 0; ti < v.earlyBirdTiers.length; ti++) {
const t = v.earlyBirdTiers[ti];
@@ -1132,7 +1158,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
}
};
const saveStagedSections = async (eventId: string, createdOptions: any[]) => {
const saveStagedSections = async (eventId: string, createdOptions: EventOption[]) => {
for (const sec of sectionDrafts) {
if (!sec.name.trim()) continue;
const optionIds = sec.optionIndices
@@ -1156,7 +1182,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
}
setSaving(true); setError(null);
try {
const body: any = {
const body: Record<string, unknown> = {
title: draft.title.trim(), description: draft.description || undefined,
startDate: new Date(draft.startDate).toISOString(), endDate: new Date(draft.endDate).toISOString(),
registrationDeadline: draft.registrationDeadline ? new Date(draft.registrationDeadline).toISOString() : undefined,
@@ -1187,6 +1213,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
if (mode === "edit") {
body.isActive = draft.isActive;
if (!ev) throw new Error("No event loaded to save");
await apiFetch(`/api/events/${ev.id}`, { method: "PUT", authToken: token || undefined, body });
await saveOptions(ev.id);
// Form is saved inside saveOptions for edit mode; avoid double call
@@ -1195,12 +1222,12 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
if (formDef.isRequired || formDef.fields.length > 0) {
body.form = { isRequired: !!formDef.isRequired, fields: formDef.fields.filter(f => f.label?.trim()).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired, order: i, helpText: f.helpText || null, options: f.type === "multiple_choice" ? cleanChoices(f.options) : undefined })) };
}
const created: any = await apiFetch("/api/events", { method: "POST", authToken: token || undefined, body });
const created = await apiFetch<Event>("/api/events", { method: "POST", authToken: token || undefined, body });
if (created?.id) {
await saveOptions(created.id);
await saveFeedback(created.id);
// Fetch event to get final option IDs for sections
const fullEvent: any = await apiFetch(`/api/events/${created.id}`).catch(() => null);
const fullEvent = await apiFetch<Event>(`/api/events/${created.id}`).catch(() => null);
const createdOptions = fullEvent?.eventOptions || [];
if (sectionDrafts.length > 0) await saveStagedSections(created.id, createdOptions);
// Upload staged attachments
@@ -1671,7 +1698,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
// ─── event card ───────────────────────────────────────────────────────────────
function EventCard({ ev, onEdit }: { ev: any; onEdit: () => void }) {
function EventCard({ ev, onEdit }: { ev: Event; onEdit: () => void }) {
const isPast = ev.endDate && new Date(ev.endDate) < new Date();
const isInactive = ev.isActive === false;
const isClosed = ev.cashupStatus === "closed";
@@ -1711,27 +1738,28 @@ export default function ManageEventsPage() {
if (!user) router.replace("/login");
}, [user, loading, router]);
const [events, setEvents] = useState<any[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [loadingEvents, setLoadingEvents] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
const loadEvents = async () => {
const loadEvents = useCallback(async () => {
if (!token) return;
setLoadingEvents(true);
try {
const evs = await apiFetch<any[]>("/api/events/all?includePast=true&includeInactive=true", { authToken: token });
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true&includeInactive=true", { authToken: token });
setEvents(Array.isArray(evs) ? evs.sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime()) : []);
} catch (e) {
setError(errorMessage(e) || "Failed to load events");
} finally {
setLoadingEvents(false);
}
};
}, [token, setError]);
useEffect(() => { loadEvents(); }, [user, token]);
// Reload when the signed-in user changes too (e.g. after an impersonation switch).
useEffect(() => { loadEvents(); }, [user, loadEvents]);
const [modalMode, setModalMode] = useState<"create" | "edit" | null>(null);
const [editingEvent, setEditingEvent] = useState<any | null>(null);
const [editingEvent, setEditingEvent] = useState<Event | null>(null);
const [includePast, setIncludePast] = useState(false);
const [showInactive, setShowInactive] = useState(false);
const [showClosed, setShowClosed] = useState(false);
@@ -1748,9 +1776,9 @@ export default function ManageEventsPage() {
}), [events, includePast, showInactive, showClosed, search, now]);
const openCreate = () => { setEditingEvent(null); setModalMode("create"); };
const openEdit = async (ev: any) => {
const openEdit = async (ev: Event) => {
try {
const full = await apiFetch<any>(`/api/events/${ev.id}`, { authToken: token || undefined });
const full = await apiFetch<Event>(`/api/events/${ev.id}`, { authToken: token || undefined });
setEditingEvent(full);
} catch { setEditingEvent(ev); }
setModalMode("edit");
@@ -1,17 +1,28 @@
"use client";
import { ExtraFieldInput, isExtraFieldType, ChoicesEditor } from "@/components/shared/FormFieldExtras";
import React, { useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { FileText } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import type { Event, EventForm, FormResponse, Registration, User } from "@/types";
type FormFieldType = 'yes_no' | 'text' | 'date' | 'numeric' | 'statement' | 'paragraph' | 'rating' | 'multiple_choice';
type EventFormDef = { isRequired: boolean; fields: { id?: string; type: FormFieldType; label: string; isRequired?: boolean; order?: number; helpText?: string|null; options?: any }[] };
type EventFormDef = { isRequired: boolean; fields: { id?: string; type: FormFieldType; label: string; isRequired?: boolean; order?: number; helpText?: string|null; options?: unknown }[] };
// The form as GET /api/events/:id returns it, mapped into the editor's shape.
const toFormDef = (f: EventForm | null | undefined): EventFormDef =>
f && Array.isArray(f.fields)
? { isRequired: !!f.isRequired, fields: f.fields.map((x) => ({ id: x.id, type: x.type, label: x.label, isRequired: !!x.isRequired, order: x.order, helpText: x.helpText || '', options: x.options || undefined })) }
: { isRequired: false, fields: [] };
// /api/forms/responses returns { items } (older builds returned a bare array).
const responseItems = (res: { items?: FormResponse[] } | FormResponse[] | null | undefined): FormResponse[] =>
Array.isArray(res) ? res : (Array.isArray(res?.items) ? res.items : []);
function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: EventFormDef) => void }) {
const fields = value.fields || [];
@@ -139,36 +150,36 @@ export default function FormsBrowserPage() {
const [mode, setMode] = useState<'view' | 'manage' | 'fill'>('view');
const [allEvents, setAllEvents] = useState<any[]>([]);
const [allEvents, setAllEvents] = useState<Event[]>([]);
const [evIncludePast, setEvIncludePast] = useState(false);
const [evIncludeInactive, setEvIncludeInactive] = useState(false);
const [selectedEventId, setSelectedEventId] = useState<string>("");
const [userId, setUserId] = useState<string>("");
const [userSearch, setUserSearch] = useState<string>("");
const [allUsers, setAllUsers] = useState<any[]>([]);
const [allUsers, setAllUsers] = useState<User[]>([]);
const [userDropdownOpen, setUserDropdownOpen] = useState(false);
const [registrationId, setRegistrationId] = useState<string>("");
const [items, setItems] = useState<any[]>([]);
const [items, setItems] = useState<FormResponse[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [loadingList, setLoadingList] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const loadEvents = async () => {
const loadEvents = useCallback(async () => {
if (!token) return;
try {
const evs = await apiFetch<any[]>("/api/events/all?includePast=true&includeInactive=true", { authToken: token });
const sorted = (evs || []).sort((a: any, b: any) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true&includeInactive=true", { authToken: token });
const sorted = (evs || []).sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
setAllEvents(Array.isArray(sorted) ? sorted : []);
} catch {}
};
useEffect(() => { loadEvents(); }, [token]);
}, [token]);
useEffect(() => { loadEvents(); }, [loadEvents]);
useEffect(() => {
if (!token) return;
fetchAllUsers(token)
.then(list => {
setAllUsers(list.sort((a: any, b: any) => (a.name || "").localeCompare(b.name || "")));
setAllUsers(list.sort((a, b) => (a.name || "").localeCompare(b.name || "")));
})
.catch(() => {});
}, [token]);
@@ -208,7 +219,7 @@ export default function FormsBrowserPage() {
if (registrationId.trim()) qs.set("registrationId", registrationId.trim());
if (append && nextCursor) qs.set("cursor", nextCursor);
const url = "/api/forms/responses" + (qs.toString() ? `?${qs.toString()}` : "");
const res = await apiFetch<{ items: any[]; nextCursor?: string }>(url, { authToken: token });
const res = await apiFetch<{ items: FormResponse[]; nextCursor?: string }>(url, { authToken: token });
const newItems = Array.isArray(res?.items) ? res!.items : [];
setItems(prev => append ? [...prev, ...newItems] : newItems);
setNextCursor(res?.nextCursor || null);
@@ -219,21 +230,25 @@ export default function FormsBrowserPage() {
}
};
// Load once the token is available; after that, searching is on demand (the
// filters don't refetch as they're typed). The ref keeps the latest `search`.
const searchRef = useRef(search);
useEffect(() => { searchRef.current = search; });
useEffect(() => {
if (token) search(false);
if (token) searchRef.current(false);
}, [token]);
const onPrint = () => {
if (!items || items.length === 0) return;
const w = window.open("", "_blank");
if (!w) return;
const formPages = items.map((r: any) => {
const formPages = items.map((r) => {
const eventTitle = r.registration?.event?.title || "Event";
const attendeeName = r.registration?.user?.name || "Attendee";
const attendeeEmail = r.registration?.user?.email || "";
const regId = String(r.registrationId || r.id || "").slice(0, 8);
const createdAt = r.createdAt ? new Date(r.createdAt).toLocaleDateString("en-ZA") : "";
const answers = (r.answers || []).map((a: any) => `
const answers = (r.answers || []).map((a) => `
<div class="answer">
<div class="label">${a.field?.label || a.fieldId || "Field"}</div>
<div class="value">${a.value ?? ""}</div>
@@ -286,36 +301,31 @@ export default function FormsBrowserPage() {
const [formLoading, setFormLoading] = useState(false);
const [formDef, setFormDef] = useState<EventFormDef>({ isRequired: false, fields: [] });
const [formLoadedFor, setFormLoadedFor] = useState<string>("");
const loadFormForEvent = async (eventId: string) => {
const loadFormForEvent = useCallback(async (eventId: string) => {
if (!eventId) return;
try {
setFormLoading(true);
const evFull = await apiFetch<any>(`/api/events/${encodeURIComponent(eventId)}`);
const f = evFull?.form;
if (f && Array.isArray(f.fields)) {
setFormDef({ isRequired: !!f.isRequired, fields: f.fields.map((x: any) => ({ id: x.id, type: x.type, label: x.label, isRequired: !!x.isRequired, order: x.order, helpText: x.helpText || '', options: x.options || undefined })) });
} else {
setFormDef({ isRequired: false, fields: [] });
}
const evFull = await apiFetch<Event>(`/api/events/${encodeURIComponent(eventId)}`);
setFormDef(toFormDef(evFull?.form));
setFormLoadedFor(eventId);
} catch (e) {
setError(errorMessage(e) || 'Failed to load form');
} finally {
setFormLoading(false);
}
};
}, [setError]);
useEffect(() => {
if (mode === 'manage' && selectedEventId && selectedEventId !== formLoadedFor) {
loadFormForEvent(selectedEventId);
}
}, [mode, selectedEventId]);
}, [mode, selectedEventId, formLoadedFor, loadFormForEvent]);
const saveForm = async () => {
if (!token || !selectedEventId) return;
setError(null); setInfo(null);
try {
const fields = (formDef.fields || []).filter(f => (f.label || '').trim().length > 0).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired && f.type !== 'statement' && f.type !== 'paragraph', order: i, helpText: f.helpText || null, options: f.type === 'multiple_choice' && Array.isArray(f.options) ? f.options.map((o: any) => String(o).trim()).filter(Boolean) : undefined }));
const fields = (formDef.fields || []).filter(f => (f.label || '').trim().length > 0).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired && f.type !== 'statement' && f.type !== 'paragraph', order: i, helpText: f.helpText || null, options: f.type === 'multiple_choice' && Array.isArray(f.options) ? f.options.map((o) => String(o).trim()).filter(Boolean) : undefined }));
await apiFetch(`/api/events/${encodeURIComponent(selectedEventId)}`, { method: 'PUT', authToken: token, body: { form: { isRequired: !!formDef.isRequired, fields } } });
setInfo('Form saved');
} catch (e) {
@@ -325,19 +335,19 @@ export default function FormsBrowserPage() {
// Fill/edit responses state
const [fillEventId, setFillEventId] = useState<string>("");
const [fillRegistrations, setFillRegistrations] = useState<any[]>([]);
const [fillRegistrations, setFillRegistrations] = useState<Registration[]>([]);
const [loadingRegs, setLoadingRegs] = useState(false);
const [fillRegistrationId, setFillRegistrationId] = useState<string>("");
const [fillRegistration, setFillRegistration] = useState<any | null>(null);
const [fillRegistration, setFillRegistration] = useState<Registration | null>(null);
const [fillForm, setFillForm] = useState<EventFormDef>({ isRequired: false, fields: [] });
const [existingResponses, setExistingResponses] = useState<any[]>([]);
const [existingResponses, setExistingResponses] = useState<FormResponse[]>([]);
const [savingFill, setSavingFill] = useState(false);
const loadRegsForEvent = async (evId: string) => {
const loadRegsForEvent = useCallback(async (evId: string) => {
if (!token || !evId) return;
try {
setLoadingRegs(true);
const regs = await apiFetch<any[]>(`/api/registrations/event/${encodeURIComponent(evId)}`, { authToken: token });
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${encodeURIComponent(evId)}`, { authToken: token });
const sorted = (Array.isArray(regs) ? regs : []).sort((a, b) => (a.user?.name || '').toLowerCase().localeCompare((b.user?.name || '').toLowerCase()));
setFillRegistrations(sorted);
} catch {
@@ -345,54 +355,48 @@ export default function FormsBrowserPage() {
} finally {
setLoadingRegs(false);
}
};
}, [token]);
useEffect(() => {
if (mode === 'fill') setFillEventId(prev => prev || selectedEventId || "");
}, [mode]);
}, [mode, selectedEventId]);
useEffect(() => {
if (mode !== 'fill') return;
if (fillEventId) loadRegsForEvent(fillEventId);
setFillRegistrationId(""); setFillRegistration(null); setExistingResponses([]); setFillForm({ isRequired: false, fields: [] });
}, [fillEventId, mode]);
}, [fillEventId, mode, loadRegsForEvent]);
useEffect(() => {
(async () => {
if (mode !== 'fill' || !fillRegistrationId) return;
try {
const reg = await apiFetch<any>(`/api/registrations/${encodeURIComponent(fillRegistrationId)}`, { authToken: token! });
const reg = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(fillRegistrationId)}`, { authToken: token! });
setFillRegistration(reg);
if (reg?.event?.id) {
const evFull = await apiFetch<any>(`/api/events/${encodeURIComponent(reg.event.id)}`);
const f = evFull?.form;
if (f && Array.isArray(f.fields)) {
setFillForm({ isRequired: !!f.isRequired, fields: f.fields.map((x:any)=>({ id:x.id, type:x.type, label:x.label, isRequired:!!x.isRequired, order:x.order, helpText:x.helpText||'', options: x.options || undefined })) });
} else {
setFillForm({ isRequired: false, fields: [] });
}
const evFull = await apiFetch<Event>(`/api/events/${encodeURIComponent(reg.event.id)}`);
setFillForm(toFormDef(evFull?.form));
}
const resList = await apiFetch<any>(`/api/forms/responses?registrationId=${encodeURIComponent(fillRegistrationId)}`, { authToken: token! });
const items = Array.isArray(resList?.items) ? resList.items : (Array.isArray(resList) ? resList : []);
setExistingResponses(items);
const resList = await apiFetch<{ items?: FormResponse[] } | FormResponse[]>(`/api/forms/responses?registrationId=${encodeURIComponent(fillRegistrationId)}`, { authToken: token! });
setExistingResponses(responseItems(resList));
} catch (e) {
setError(errorMessage(e) || 'Failed to load registration/form');
}
})();
}, [fillRegistrationId, mode]);
}, [fillRegistrationId, mode, token, setError]);
const mainTicketCount = useMemo(() => {
const ros = fillRegistration?.registrationOptions || [];
return ros.filter((ro:any)=> ro?.eventOption?.isMainTicket).reduce((s:number, ro:any)=> s + (ro.quantity||0), 0);
return ros.filter((ro)=> ro?.eventOption?.isMainTicket).reduce((s, ro)=> s + (ro.quantity||0), 0);
}, [fillRegistration]);
const [editableResponses, setEditableResponses] = useState<{ answers: Record<string,string> }[]>([]);
useEffect(() => {
if (mode !== 'fill') return;
const fields = (fillForm.fields || []).filter(f => f.type !== 'statement' && f.type !== 'paragraph');
const mapped: { answers: Record<string,string> }[] = (existingResponses || []).map((r:any)=>{
const mapped: { answers: Record<string,string> }[] = (existingResponses || []).map((r)=>{
const ans: Record<string,string> = {};
(r.answers || []).forEach((a:any)=>{ if (a.fieldId) ans[a.fieldId] = String(a.value ?? ''); });
(r.answers || []).forEach((a)=>{ if (a.fieldId) ans[a.fieldId] = String(a.value ?? ''); });
for (const f of fields) if (!(f.id! in ans)) ans[f.id!] = '';
return { answers: ans };
});
@@ -409,7 +413,7 @@ export default function FormsBrowserPage() {
const setAnswer = (respIdx: number, fieldId: string, value: string) => {
setEditableResponses(prev => {
const copy = prev.slice();
if (!copy[respIdx]) copy[respIdx] = { answers: {} } as any;
if (!copy[respIdx]) copy[respIdx] = { answers: {} };
copy[respIdx] = { answers: { ...copy[respIdx].answers, [fieldId]: value } };
return copy;
});
@@ -432,9 +436,8 @@ export default function FormsBrowserPage() {
method: 'PUT', authToken: token, body: { responses: editableResponses }
});
setInfo('Responses saved');
const resList = await apiFetch<any>(`/api/forms/responses?registrationId=${encodeURIComponent(fillRegistrationId)}`, { authToken: token! });
const items = Array.isArray(resList?.items) ? resList.items : (Array.isArray(resList) ? resList : []);
setExistingResponses(items);
const resList = await apiFetch<{ items?: FormResponse[] } | FormResponse[]>(`/api/forms/responses?registrationId=${encodeURIComponent(fillRegistrationId)}`, { authToken: token! });
setExistingResponses(responseItems(resList));
} catch (e) {
setError(errorMessage(e) || 'Failed to save responses');
} finally {
@@ -574,7 +577,7 @@ export default function FormsBrowserPage() {
</div>
) : (
<ul className="divide-y print-form-wrapper">
{items.map((r: any) => (
{items.map((r) => (
<li key={r.id} className="print-page-break">
<div className="p-4 print-response">
{/* Header */}
@@ -594,7 +597,7 @@ export default function FormsBrowserPage() {
<div className="text-xs text-gray-400 italic">No answers submitted.</div>
) : (
<div className="grid sm:grid-cols-2 gap-2 print-answers">
{r.answers.map((a: any) => (
{r.answers.map((a) => (
<div key={a.id} className="bg-gray-50 border rounded p-2 print-answer">
<div className="text-[10px] text-gray-500 mb-0.5 print-answer-label">{a.field?.label || a.fieldId}</div>
<div className="font-medium text-sm break-words print-answer-value">{a.value}</div>
@@ -681,7 +684,7 @@ export default function FormsBrowserPage() {
<label className="block text-xs text-gray-600 mb-1">Registration</label>
<select className="border rounded px-2 py-1.5 text-sm w-full" value={fillRegistrationId} onChange={e => setFillRegistrationId(e.target.value)} disabled={!fillEventId || loadingRegs}>
<option value="">{loadingRegs ? 'Loading registrations…' : 'Select registration…'}</option>
{fillRegistrations.map((r:any)=>{
{fillRegistrations.map((r)=>{
const label = `${r.user?.name || r.userId} — ${r.user?.email || ''} — #${String(r.id).slice(0,8)}`;
return <option key={r.id} value={r.id}>{label}</option>;
})}
@@ -703,7 +706,7 @@ export default function FormsBrowserPage() {
<div key={idx} className="border rounded-lg p-3 bg-white shadow-sm">
<div className="text-sm font-semibold mb-3 text-gray-700">Attendee #{idx+1}</div>
<div className="grid sm:grid-cols-2 gap-3">
{fillForm.fields.filter(f=>f.type!=='statement' && f.type !== 'paragraph').map((f:any)=> (
{fillForm.fields.filter((f): f is typeof f & { id: string } => !!f.id && f.type!=='statement' && f.type !== 'paragraph').map((f)=> (
<div key={f.id}>
<label className="block text-xs text-gray-600 mb-1">{f.label}{f.isRequired ? ' *' : ''}</label>
{f.type === 'text' && (
@@ -10,33 +10,33 @@ import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeFi
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
import { UserPlus, CreditCard } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import type { EarlyBirdTier, Event, EventOption, OptionVariant, Registration, RegistrationOption, User } from "@/types";
// ─── 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;
// The earliest-deadline tier that's still open, or undefined when none are.
function currentTier(tiers: EarlyBirdTier[]): EarlyBirdTier | undefined {
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;
return tiers
.map((t) => ({ ...t, deadlineAt: new Date(t.deadline) }))
.filter((t) => now < t.deadlineAt)
.sort((a, b) => a.deadlineAt.getTime() - b.deadlineAt.getTime() || (a.order || 0) - (b.order || 0) || a.price - b.price)[0];
}
function effectiveVariantUnit(opt: any, variant: any): number {
function effectiveOptionUnit(opt: EventOption): number {
const base = opt.price || 0;
const tiers = (Array.isArray(opt.earlyBirdTiers) ? opt.earlyBirdTiers : []).filter((t) => !t.variantId);
if (tiers.length === 0) return base;
return currentTier(tiers)?.price ?? base;
}
function effectiveVariantUnit(opt: EventOption, variant: OptionVariant): 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);
const variantTiers = allTiers.filter((t) => t.variantId === variant.id);
const tiers = variantTiers.length > 0 ? variantTiers : allTiers.filter((t) => !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;
return currentTier(tiers)?.price ?? base;
}
function fmtPrice(n: number) {
@@ -46,11 +46,12 @@ function fmtPrice(n: number) {
// 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 {
function optionUnitPrice(opt: RegistrationOption): number {
if (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) {
return Number(opt.priceSnapshot);
}
const eo = opt.eventOption;
if (!eo) return 0;
if (opt.variant) return effectiveVariantUnit(eo, opt.variant);
return effectiveOptionUnit(eo);
}
@@ -71,12 +72,12 @@ export default function ManualRegistrationPage() {
if (!user) router.replace("/login");
}, [user, loading, router]);
const [events, setEvents] = useState<any[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [selectedEventId, setSelectedEventId] = useState<string>("");
const [options, setOptions] = useState<any[]>([]);
const [options, setOptions] = useState<EventOption[]>([]);
// All system users for fuzzy lookup
const [allUsers, setAllUsers] = useState<any[]>([]);
const [allUsers, setAllUsers] = useState<User[]>([]);
const [guest, setGuest] = useState({ name: "", email: "", phoneNumber: "" });
const [notifPref, setNotifPref] = useState<"email" | "whatsapp" | "both">("email");
@@ -95,7 +96,7 @@ export default function ManualRegistrationPage() {
const [tab, setTab] = useState<"register" | "payment">("register");
// ── Record Payment tab state ─────────────────────────────────────────────
const [allRegistrations, setAllRegistrations] = useState<any[]>([]);
const [allRegistrations, setAllRegistrations] = useState<Registration[]>([]);
const [payUserId, setPayUserId] = useState<string>("");
const [payUserQuery, setPayUserQuery] = useState("");
const [payDropdownOpen, setPayDropdownOpen] = useState(false);
@@ -120,7 +121,7 @@ export default function ManualRegistrationPage() {
// balances can be computed without a per-registration fetch loop).
useEffect(() => {
if (!token) return;
apiFetch<any[]>("/api/registrations", { authToken: token })
apiFetch<Registration[]>("/api/registrations", { authToken: token })
.then(regs => setAllRegistrations(Array.isArray(regs) ? regs : []))
.catch(() => {});
}, [token]);
@@ -128,8 +129,8 @@ export default function ManualRegistrationPage() {
const regOutstanding = useMemo(() => {
const map: Record<string, { totalDue: number; totalPaid: number; outstanding: number }> = {};
for (const r of allRegistrations) {
const totalDue = r.totalDueComputed ?? (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);
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => sum + optionUnitPrice(opt) * (opt.quantity || 0), 0);
const totalPaid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
map[r.id] = { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid) };
}
return map;
@@ -145,7 +146,7 @@ export default function ManualRegistrationPage() {
.map(x => x.u);
}, [payUserQuery, allUsers]);
const selectPayUser = (u: any) => {
const selectPayUser = (u: User) => {
setPayUserId(u.id);
setPayUserQuery(u.name || "");
setPayDropdownOpen(false);
@@ -166,9 +167,9 @@ export default function ManualRegistrationPage() {
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());
.filter((r) => String(r.userId || r.user?.id) === String(payUserId))
.filter((r) => (regOutstanding[r.id]?.outstanding ?? 0) > 0.000001)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
}, [allRegistrations, payUserId, regOutstanding]);
const createManualPayment = async () => {
@@ -180,7 +181,7 @@ export default function ManualRegistrationPage() {
if (!payRegistrationId) { setPayError("Please select a registration"); return; }
try {
setPaySubmitting(true);
await apiFetch<any>("/api/payments", {
await apiFetch("/api/payments", {
method: "POST",
authToken: token,
body: {
@@ -197,7 +198,7 @@ export default function ManualRegistrationPage() {
setPayPaidAtLocal("");
// Refresh registrations so the displayed outstanding balance updates
try {
const regs = await apiFetch<any[]>("/api/registrations", { authToken: token });
const regs = await apiFetch<Registration[]>("/api/registrations", { authToken: token });
setAllRegistrations(Array.isArray(regs) ? regs : []);
} catch {}
} catch (e) {
@@ -230,7 +231,7 @@ export default function ManualRegistrationPage() {
const selectedEventAllowsNames = !!selectedEv?.allowNamedAttendees || selectedIsYouth;
const [pickedUserId, setPickedUserId] = useState<string | null>(null);
const selectUser = (u: any) => {
const selectUser = (u: User) => {
setPickedUserId(u.id || null);
setGuest({ name: u.name || "", email: u.email || "", phoneNumber: u.phoneNumber || "" });
setUserQuery(u.name || "");
@@ -252,7 +253,7 @@ export default function ManualRegistrationPage() {
(async () => {
try {
if (!token) return;
const evs = await apiFetch<any[]>("/api/events/all", { authToken: token });
const evs = await apiFetch<Event[]>("/api/events/all", { authToken: token });
const now = Date.now();
const active = (evs || []).filter(ev => {
const t = new Date(ev.endDate).getTime();
@@ -274,9 +275,9 @@ export default function ManualRegistrationPage() {
const opts = ev.options || ev.eventOptions || [];
setOptions(opts);
const map: Record<string, number> = {};
opts.forEach((o: any) => {
opts.forEach((o) => {
if ((o.variants || []).length > 0) {
(o.variants as any[]).forEach(v => { map[`${o.id}::${v.id}`] = 0; });
(o.variants || []).forEach(v => { map[`${o.id}::${v.id}`] = 0; });
} else {
map[o.id] = 0;
}
@@ -291,7 +292,7 @@ export default function ManualRegistrationPage() {
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 + (o.variants || []).reduce((vs, v) => vs + (quantities[`${o.id}::${v.id}`] || 0) * effectiveVariantUnit(o, v), 0);
}
return sum + (quantities[o.id] || 0) * effectiveOptionUnit(o);
}, 0);
@@ -320,7 +321,7 @@ export default function ManualRegistrationPage() {
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", {
const res = await apiFetch<Registration & { couponError?: string }>("/api/registrations/manual", {
method: "POST",
authToken: token,
body: {
@@ -340,7 +341,7 @@ export default function ManualRegistrationPage() {
// 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)]);
setAllRegistrations(prev => [res, ...prev.filter((r) => r.id !== res.id)]);
setPayUserId(res.userId || res.user?.id || "");
setPayUserQuery(res.user?.name || guest.name || "");
setPayRegistrationId(res.id);
@@ -559,7 +560,7 @@ export default function ManualRegistrationPage() {
<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) => {
{(opt.variants || []).map((v) => {
const unit = effectiveVariantUnit(opt, v);
const basePrice = v.price !== null && v.price !== undefined ? v.price : opt.price;
const key = `${opt.id}::${v.id}`;
@@ -719,7 +720,7 @@ export default function ManualRegistrationPage() {
<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) => {
{regsForPayUser.map((r) => {
const out = regOutstanding[r.id]?.outstanding ?? 0;
const label = `${r.event?.title || r.eventId || 'Event'} — Outstanding: R ${out.toFixed(2)} — #${String(r.id).slice(0,8)}`;
return <option key={r.id} value={r.id} title={label}>{label}</option>;
+10 -1
View File
@@ -93,6 +93,8 @@ export type EventForm = {
fields: EventFormField[];
};
export type EventAttachment = { id: string; eventId?: string; originalName: string; filename?: string; mimeType: string; size: number; url: string; createdAt?: string };
export type Event = {
id: string;
title: string;
@@ -128,9 +130,13 @@ export type Event = {
contactPhone?: string | null;
contactEmail?: string | null;
createdById?: string | null;
// Supervisor views of an event include who created it and who gets its notifications.
createdBy?: UserSummary | null;
notifyRecipients?: UserSummary[];
redirectUrl?: string | null;
options?: EventOption[];
eventOptions?: EventOption[];
attachments?: EventAttachment[];
form?: EventForm | null;
createdAt?: string;
updatedAt?: string;
@@ -274,7 +280,10 @@ export type Attendee = {
};
export type FormAnswer = { id: string; responseId?: string; fieldId: string; value: string; field?: { label: string } | null };
export type FormResponse = { id: string; registrationId?: string; attendeeId?: string | null; createdAt: string; answers?: FormAnswer[] };
export type FormResponse = {
id: string; registrationId?: string; attendeeId?: string | null; createdAt: string; answers?: FormAnswer[];
registration?: Registration;
};
export type Registration = {
id: string;