Supervisors can split an event's named attendees into allocation sets (rooms, small groups, buses) with capacity, gender and leader per group, using a drag-and-drop board or auto-allocate (keep bookings together or split them). Adds printable group and master lists (medical view audited), the group on tickets and in My Events, messaging by group, a "My groups" page for leaders, and an optional per-event gender question for named attendees. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
213 lines
12 KiB
TypeScript
213 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import React, { useMemo, useState } from "react";
|
|
import { Users, Send } from "lucide-react";
|
|
import { apiFetch } from "@/lib/api";
|
|
import { isMinorOn } from "@/components/shared/AttendeeNamesEditor";
|
|
|
|
const CONSENT_LABEL: Record<string, { text: string; cls: string }> = {
|
|
pending: { text: "consent pending", cls: "text-amber-700" },
|
|
given: { text: "consent given", cls: "text-green-700" },
|
|
declined: { text: "consent declined", cls: "text-red-700" },
|
|
};
|
|
|
|
type Attendee = {
|
|
id: string; eventOptionId: string; variantId: string | null; name: string; email: string | null; phone: string | null;
|
|
dateOfBirth?: string | null; gender?: string | null; parentName?: string | null; parentEmail?: string | null; parentPhone?: string | null;
|
|
ticket?: { id: string; isUsed: boolean } | null;
|
|
consentRequests?: { id: string; status: "pending" | "given" | "declined" }[];
|
|
};
|
|
type Row = {
|
|
id?: string; eventOptionId: string; variantId: string | null; label: string; name: string; email: string; phone: string; locked: boolean;
|
|
dateOfBirth: string; gender: string; parentName: string; parentEmail: string; parentPhone: string;
|
|
};
|
|
|
|
/**
|
|
* Named attendees on a registration (group tickets): shows who each main-ticket place
|
|
* is for, and lets the payer (or staff) name places, change details, choose where the
|
|
* tickets go, and send an attendee the link to their own form.
|
|
*/
|
|
export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved, onMessage }: {
|
|
registration: any;
|
|
token: string;
|
|
canEdit: boolean;
|
|
hasForm: boolean;
|
|
onSaved: () => void;
|
|
onMessage: (msg: string) => void;
|
|
}) {
|
|
const attendees: Attendee[] = registration.attendees || [];
|
|
const youth = !!registration.event?.requiresParentalConsent;
|
|
const collectGender = !!registration.event?.collectGender;
|
|
const eventStart: string | undefined = registration.event?.startDate;
|
|
const [editing, setEditing] = useState(false);
|
|
const [rows, setRows] = useState<Row[]>([]);
|
|
const [delivery, setDelivery] = useState<"payer" | "each">(registration.ticketDelivery || "payer");
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// One row per main-ticket place: named ones first (in order), then blanks.
|
|
const buildRows = useMemo(() => () => {
|
|
const out: Row[] = [];
|
|
for (const ro of registration.registrationOptions || []) {
|
|
if (!ro.eventOption?.isMainTicket) continue;
|
|
const label = `${ro.eventOption?.name || "Ticket"}${ro.variant?.name ? ` (${ro.variant.name})` : ""}`;
|
|
const named = attendees.filter(a => a.eventOptionId === ro.eventOptionId && (a.variantId || null) === (ro.variantId || null));
|
|
for (let i = 0; i < (ro.quantity || 0); i++) {
|
|
const a = named[i];
|
|
out.push({
|
|
id: a?.id, eventOptionId: ro.eventOptionId, variantId: ro.variantId || null, label: `${label} ${i + 1}`,
|
|
name: a?.name || "", email: a?.email || "", phone: a?.phone || "",
|
|
dateOfBirth: a?.dateOfBirth ? String(a.dateOfBirth).slice(0, 10) : "",
|
|
gender: a?.gender || "",
|
|
parentName: a?.parentName || "", parentEmail: a?.parentEmail || "", parentPhone: a?.parentPhone || "",
|
|
locked: !!a?.ticket?.isUsed,
|
|
});
|
|
}
|
|
}
|
|
return out;
|
|
}, [registration, attendees]);
|
|
|
|
if (!registration.event?.allowNamedAttendees && !youth && attendees.length === 0) return null;
|
|
|
|
const startEdit = () => { setRows(buildRows()); setDelivery(registration.ticketDelivery || "payer"); setError(null); setEditing(true); };
|
|
const update = (i: number, field: "name" | "email" | "phone" | "dateOfBirth" | "gender" | "parentName" | "parentEmail" | "parentPhone", value: string) =>
|
|
setRows(prev => prev.map((r, j) => (j === i ? { ...r, [field]: value } : r)));
|
|
|
|
const save = async () => {
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
await apiFetch(`/api/registrations/${encodeURIComponent(registration.id)}/attendees`, {
|
|
method: "PUT",
|
|
authToken: token,
|
|
body: {
|
|
ticketDelivery: delivery,
|
|
attendees: rows.filter(r => r.name.trim()).map(r => ({
|
|
...(r.id ? { id: r.id } : {}),
|
|
eventOptionId: r.eventOptionId,
|
|
...(r.variantId ? { variantId: r.variantId } : {}),
|
|
name: r.name.trim(),
|
|
email: r.email.trim() || undefined,
|
|
phone: r.phone.trim() || undefined,
|
|
...(r.dateOfBirth ? { dateOfBirth: r.dateOfBirth } : {}),
|
|
...(r.gender ? { gender: r.gender } : {}),
|
|
...(r.parentName.trim() ? { parentName: r.parentName.trim() } : {}),
|
|
...(r.parentEmail.trim() ? { parentEmail: r.parentEmail.trim() } : {}),
|
|
...(r.parentPhone.trim() ? { parentPhone: r.parentPhone.trim() } : {}),
|
|
})),
|
|
},
|
|
});
|
|
setEditing(false);
|
|
onMessage("Attendees saved.");
|
|
onSaved();
|
|
} catch (e: any) {
|
|
setError(e?.message || "Couldn't save attendees");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const sendLink = async (a: Attendee) => {
|
|
try {
|
|
const res = await apiFetch<{ message: string }>(`/api/registrations/${encodeURIComponent(registration.id)}/attendees/${encodeURIComponent(a.id)}/send-form-link`, { method: "POST", authToken: token });
|
|
onMessage(res.message);
|
|
} catch (e: any) {
|
|
setError(e?.message || "Couldn't send the form link");
|
|
}
|
|
};
|
|
|
|
const answered = new Set((registration.formResponses || []).map((r: any) => r.attendeeId).filter(Boolean));
|
|
|
|
const resendConsent = async (requestId: string) => {
|
|
try {
|
|
const res = await apiFetch<{ message: string }>(`/api/consent/requests/${encodeURIComponent(requestId)}/resend`, { method: "POST", authToken: token });
|
|
onMessage(res.message);
|
|
} catch (e: any) {
|
|
setError(e?.message || "Couldn't re-send the consent request");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<div className="font-medium mb-1 flex items-center justify-between">
|
|
<span className="flex items-center gap-1.5"><Users className="w-4 h-4 text-brand-600" /> People</span>
|
|
{canEdit && !editing && (
|
|
<button onClick={startEdit} className="px-2.5 py-1 text-xs bg-brand-600 text-white rounded hover:bg-brand-700">{attendees.length ? "Edit names" : "Add names"}</button>
|
|
)}
|
|
</div>
|
|
|
|
{!editing ? (
|
|
attendees.length === 0 ? (
|
|
<p className="text-sm text-gray-500">No names yet — add a name for each person to give them their own ticket.</p>
|
|
) : (
|
|
<>
|
|
<ul className="text-sm space-y-1">
|
|
{attendees.map(a => (
|
|
<li key={a.id} className="flex items-center justify-between gap-2">
|
|
<span>
|
|
{a.name}
|
|
{(a.email || a.phone) && <span className="text-xs text-gray-500"> · {[a.email, a.phone].filter(Boolean).join(" · ")}</span>}
|
|
{hasForm && <span className={`ml-2 text-xs ${answered.has(a.id) ? "text-green-700" : "text-amber-700"}`}>{answered.has(a.id) ? "form done" : "form to do"}</span>}
|
|
{youth && a.consentRequests?.[0] && (
|
|
<span className={`ml-2 text-xs ${CONSENT_LABEL[a.consentRequests[0].status]?.cls}`}>{CONSENT_LABEL[a.consentRequests[0].status]?.text}</span>
|
|
)}
|
|
</span>
|
|
{canEdit && youth && a.consentRequests?.[0]?.status === "pending" && (
|
|
<button onClick={() => resendConsent(a.consentRequests![0].id)} className="text-xs text-brand-700 hover:underline shrink-0 inline-flex items-center gap-1"><Send className="w-3 h-3" /> Resend consent</button>
|
|
)}
|
|
{canEdit && hasForm && !answered.has(a.id) && (a.email || a.phone) && (
|
|
<button onClick={() => sendLink(a)} className="text-xs text-brand-700 hover:underline shrink-0 inline-flex items-center gap-1"><Send className="w-3 h-3" /> Send form link</button>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
<p className="text-xs text-gray-500 mt-1">Tickets go {registration.ticketDelivery === "each" ? "to each person (or you, if they have no contact details)" : "to you"}.</p>
|
|
</>
|
|
)
|
|
) : (
|
|
<div className="border rounded p-3 space-y-2 bg-gray-50">
|
|
{rows.map((r, i) => (
|
|
<div key={i}>
|
|
<div className="text-xs font-semibold text-gray-600 mb-1">{r.label}{r.locked && <span className="font-normal text-gray-500"> — checked in, can't be changed</span>}</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
|
<input className="border rounded px-2 py-1 text-sm" placeholder="Full name" value={r.name} disabled={r.locked} onChange={e => update(i, "name", e.target.value)} aria-label={`${r.label} name`} />
|
|
<input className="border rounded px-2 py-1 text-sm" placeholder="Email (optional)" value={r.email} disabled={r.locked} onChange={e => update(i, "email", e.target.value)} aria-label={`${r.label} email`} />
|
|
<input className="border rounded px-2 py-1 text-sm" placeholder="Phone (optional)" value={r.phone} disabled={r.locked} onChange={e => update(i, "phone", e.target.value)} aria-label={`${r.label} phone`} />
|
|
</div>
|
|
{collectGender && (
|
|
<select className="mt-1 border rounded px-2 py-1 text-sm" value={r.gender} disabled={r.locked} onChange={e => update(i, "gender", e.target.value)} aria-label={`${r.label} gender`}>
|
|
<option value="">Gender…</option>
|
|
<option value="male">Male</option>
|
|
<option value="female">Female</option>
|
|
</select>
|
|
)}
|
|
{youth && (
|
|
<div className="mt-1 space-y-1">
|
|
<input type="date" className="border rounded px-2 py-1 text-sm" value={r.dateOfBirth} disabled={r.locked} onChange={e => update(i, "dateOfBirth", e.target.value)} aria-label={`${r.label} date of birth`} />
|
|
{isMinorOn(r.dateOfBirth, eventStart) && (
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
|
<input className="border rounded px-2 py-1 text-sm" placeholder="Parent/guardian name" value={r.parentName} disabled={r.locked} onChange={e => update(i, "parentName", e.target.value)} aria-label={`${r.label} parent name`} />
|
|
<input className="border rounded px-2 py-1 text-sm" placeholder="Parent email" value={r.parentEmail} disabled={r.locked} onChange={e => update(i, "parentEmail", e.target.value)} aria-label={`${r.label} parent email`} />
|
|
<input className="border rounded px-2 py-1 text-sm" placeholder="Parent phone" value={r.parentPhone} disabled={r.locked} onChange={e => update(i, "parentPhone", e.target.value)} aria-label={`${r.label} parent phone`} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
<div className="text-sm pt-1">
|
|
<label className="flex items-center gap-2"><input type="radio" checked={delivery === "payer"} onChange={() => setDelivery("payer")} /> Send all tickets to me</label>
|
|
<label className="flex items-center gap-2"><input type="radio" checked={delivery === "each"} onChange={() => setDelivery("each")} /> Send each person their own</label>
|
|
</div>
|
|
{error && <p className="text-xs text-red-600">{error}</p>}
|
|
<div className="flex gap-2">
|
|
<button onClick={() => setEditing(false)} className="px-3 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200" disabled={busy}>Cancel</button>
|
|
<button onClick={save} disabled={busy} className="px-3 py-1 text-xs rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50">{busy ? "Saving…" : "Save"}</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{!editing && error && <p className="text-xs text-red-600 mt-1">{error}</p>}
|
|
</div>
|
|
);
|
|
}
|