Files
hope-events/frontend/src/app/dashboard/supervisor/forms/page.tsx
T
joshuaandClaude Sonnet 5 f3e6525467 Add registration status badges and auto-dismissing dashboard messages
Two consistency fixes requested after the payment-method work:

1. Registration status (pending/confirmed/partial_paid/paid/cancelled)
   was printed as a raw string on the user dashboard. Added
   RegistrationStatusBadge mirroring the existing EventStatusBadge
   pattern, using the same status colors already established on
   dashboard/admin/registrations.

2. Inline success/error banners across dashboard pages persisted
   indefinitely. Added a shared useDismissingState hook (drop-in
   useState replacement that auto-clears a truthy value after 7s,
   resetting the timer on each update) and swapped it in across ~24
   dashboard files. Excluded: message-only modal dialogs (ticket-
   scanning's success/error confirmations) and two states that mix
   live form-validation feedback with async results inside actively-
   open forms (the registration-edit modal's editError, the event
   create/edit modal's error) - those keep persisting until the user
   acts, since auto-hiding a "fix this field" message mid-edit would
   be a regression. Also fixed at-the-door's existing bespoke
   auto-dismiss timers (10s/15s, one mislabeled as "5s") to the same
   consistent 7s, and removed admin/settings' manual x dismiss button
   in favor of the same auto-only behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 10:17:53 +02:00

735 lines
36 KiB
TypeScript

"use client";
import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
type FormFieldType = 'yes_no' | 'text' | 'date' | 'numeric' | 'statement' | 'paragraph';
type EventFormDef = { isRequired: boolean; fields: { id?: string; type: FormFieldType; label: string; isRequired?: boolean; order?: number; helpText?: string|null; options?: any }[] };
function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: EventFormDef) => void }) {
const fields = value.fields || [];
const addField = (type: FormFieldType = 'text') => {
onChange({ ...value, fields: [...fields, { type, label: '', isRequired: false, helpText: '' }] });
};
const updateField = (idx: number, patch: Partial<EventFormDef['fields'][number]>) => {
const copy = fields.slice();
copy[idx] = { ...copy[idx], ...patch };
onChange({ ...value, fields: copy });
};
const removeField = (idx: number) => {
const copy = fields.slice();
copy.splice(idx, 1);
onChange({ ...value, fields: copy });
};
const moveField = (idx: number, dir: -1 | 1) => {
const copy = fields.slice();
const target = idx + dir;
if (target < 0 || target >= copy.length) return;
[copy[idx], copy[target]] = [copy[target], copy[idx]];
onChange({ ...value, fields: copy });
};
const typeLabel = (type: FormFieldType) => {
const labels: Record<FormFieldType, string> = { text: 'Text', numeric: 'Number', date: 'Date', yes_no: 'Yes/No', statement: 'Statement', paragraph: 'Heading' };
return labels[type] || type;
};
return (
<div className="border rounded-lg p-3 bg-gray-50">
<div className="flex items-center justify-between mb-3">
<div className="text-sm font-semibold text-gray-800">Registration Form Fields</div>
<label className="text-xs flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" checked={!!value.isRequired} onChange={e => onChange({ ...value, isRequired: e.target.checked })} />
<span>Required before tickets generate</span>
</label>
</div>
{fields.length === 0 ? (
<div className="text-xs text-gray-500 mb-3 italic">No fields yet add questions below.</div>
) : (
<ul className="space-y-2 mb-3">
{fields.map((f, idx) => (
<li key={idx} className="bg-white border rounded-lg p-2.5 shadow-sm">
<div className="flex items-center gap-1 mb-2">
<span className="text-[10px] px-1.5 py-0.5 rounded bg-indigo-50 text-indigo-700 font-medium shrink-0">{typeLabel(f.type)}</span>
<span className="text-xs text-gray-400 shrink-0">#{idx + 1}</span>
<div className="flex-1" />
<button type="button" className="text-gray-400 hover:text-gray-700 px-1" onClick={() => moveField(idx, -1)} disabled={idx === 0} title="Move up"></button>
<button type="button" className="text-gray-400 hover:text-gray-700 px-1" onClick={() => moveField(idx, 1)} disabled={idx === fields.length - 1} title="Move down"></button>
<button type="button" className="text-xs px-2 py-0.5 rounded bg-red-50 text-red-700 hover:bg-red-100 border border-red-200" onClick={() => removeField(idx)}>Remove</button>
</div>
<div className="flex flex-wrap items-center gap-2 mb-2">
<select
className="border rounded px-2 py-1 text-xs"
value={f.type}
onChange={e => updateField(idx, { type: e.target.value as FormFieldType })}
>
<option value="text">Text answer</option>
<option value="numeric">Number</option>
<option value="date">Date</option>
<option value="yes_no">Yes / No</option>
<option value="statement">Statement (display text)</option>
<option value="paragraph">Heading + paragraph</option>
</select>
{f.type !== 'statement' && f.type !== 'paragraph' && (
<label className="text-xs flex items-center gap-1 cursor-pointer">
<input type="checkbox" checked={!!f.isRequired} onChange={e => updateField(idx, { isRequired: e.target.checked })} />
Required
</label>
)}
</div>
<input
className="border rounded px-2 py-1 text-sm w-full mb-1.5"
placeholder={f.type === 'paragraph' ? 'Heading text' : f.type === 'statement' ? 'Statement text' : 'Question / field label'}
value={f.label}
onChange={e => updateField(idx, { label: e.target.value })}
/>
{f.type === 'paragraph' ? (
<textarea
className="border rounded px-2 py-1 text-xs w-full"
placeholder="Paragraph body text"
rows={2}
value={f.helpText || ''}
onChange={e => updateField(idx, { helpText: e.target.value })}
/>
) : f.type !== 'statement' ? (
<input
className="border rounded px-2 py-1 text-xs w-full text-gray-500"
placeholder="Help text shown below the field (optional)"
value={f.helpText || ''}
onChange={e => updateField(idx, { helpText: e.target.value })}
/>
) : null}
</li>
))}
</ul>
)}
<div className="flex gap-2 flex-wrap">
<button type="button" className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={() => addField('text')}>+ Add question</button>
<button type="button" className="text-xs px-3 py-1.5 rounded bg-gray-200 text-gray-700 hover:bg-gray-300" onClick={() => addField('statement')}>+ Statement</button>
<button type="button" className="text-xs px-3 py-1.5 rounded bg-gray-200 text-gray-700 hover:bg-gray-300" onClick={() => addField('paragraph')}>+ Heading</button>
</div>
</div>
);
}
export default function FormsBrowserPage() {
const { user, loading, token } = useAuth();
const router = useRouter();
const canView = useMemo(() => {
const role = user?.role;
return role === "admin" || role === "supervisor" || role === "staff";
}, [user]);
useEffect(() => {
if (loading) return;
if (!user) router.replace("/login");
}, [user, loading, router]);
const [mode, setMode] = useState<'view' | 'manage' | 'fill'>('view');
const [allEvents, setAllEvents] = useState<any[]>([]);
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 [userDropdownOpen, setUserDropdownOpen] = useState(false);
const [registrationId, setRegistrationId] = useState<string>("");
const [items, setItems] = useState<any[]>([]);
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 () => {
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());
setAllEvents(Array.isArray(sorted) ? sorted : []);
} catch {}
};
useEffect(() => { loadEvents(); }, [token]);
useEffect(() => {
if (!token) return;
fetchAllUsers(token)
.then(list => {
setAllUsers(list.sort((a: any, b: any) => (a.name || "").localeCompare(b.name || "")));
})
.catch(() => {});
}, [token]);
const filteredUsers = useMemo(() => {
const q = userSearch.trim().toLowerCase();
if (!q) return allUsers.slice(0, 50);
return allUsers.filter(u =>
(u.name || "").toLowerCase().includes(q) ||
(u.email || "").toLowerCase().includes(q) ||
(u.phoneNumber || "").toLowerCase().includes(q)
).slice(0, 50);
}, [allUsers, userSearch]);
const isAdmin = user?.role === "admin";
const events = useMemo(() => {
const now = Date.now();
return allEvents.filter(ev => {
if (!evIncludeInactive && ev.isActive === false) return false;
if (!evIncludePast) {
const t = new Date(ev.endDate).getTime();
if (!isNaN(t) && t < now) return false;
}
return true;
});
}, [allEvents, evIncludePast, evIncludeInactive]);
const search = async (append = false) => {
if (!token) return;
setError(null);
try {
setLoadingList(true);
const qs = new URLSearchParams();
if (selectedEventId) qs.set("eventId", selectedEventId);
if (userId.trim()) qs.set("userId", userId.trim());
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 newItems = Array.isArray(res?.items) ? res!.items : [];
setItems(prev => append ? [...prev, ...newItems] : newItems);
setNextCursor(res?.nextCursor || null);
} catch (e: any) {
setError(e?.message || "Failed to load form responses");
} finally {
setLoadingList(false);
}
};
useEffect(() => {
if (token) search(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 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) => `
<div class="answer">
<div class="label">${a.field?.label || a.fieldId || "Field"}</div>
<div class="value">${a.value ?? ""}</div>
</div>`).join("");
return `
<div class="page">
<div class="header">
<div>
<div class="event">${eventTitle}</div>
<div class="regid">Registration #${regId}${createdAt ? ` · ${createdAt}` : ""}</div>
</div>
<div class="attendee">
<div class="name">${attendeeName}</div>
<div class="email">${attendeeEmail}</div>
</div>
</div>
<div class="answers">${answers || '<div class="empty">No answers submitted.</div>'}</div>
</div>`;
}).join("");
w.document.write(`<!doctype html><html><head><title>Form Responses</title>
<style>
@page { size: A4; margin: 15mm; }
* { box-sizing: border-box; }
body { font-family: Arial, Helvetica, sans-serif; margin: 0; background: #fff; color: #111; }
.page { page-break-after: always; break-after: page; padding-bottom: 8mm; }
.page:last-child { page-break-after: avoid; break-after: avoid; }
.header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 2px solid #111; padding-bottom: 6px; margin-bottom: 12px; }
.event { font-size: 16px; font-weight: bold; }
.regid { font-size: 11px; color: #555; margin-top: 2px; }
.attendee { text-align: right; }
.name { font-size: 14px; font-weight: 600; }
.email { font-size: 11px; color: #555; }
.answers { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.answer { border: 1px solid #d1d5db; border-radius: 4px; padding: 8px; }
.label { font-size: 10px; color: #6b7280; margin-bottom: 3px; text-transform: uppercase; letter-spacing: 0.03em; }
.value { font-size: 13px; font-weight: 500; word-break: break-word; }
.empty { font-size: 12px; color: #9ca3af; font-style: italic; grid-column: 1 / -1; }
</style>
</head><body>${formPages}
<script>
if(document.readyState==='complete') window.print();
else window.addEventListener('load', function(){ window.print(); });
</script>
</body></html>`);
w.document.close();
w.focus();
};
// Manage forms state
const [formLoading, setFormLoading] = useState(false);
const [formDef, setFormDef] = useState<EventFormDef>({ isRequired: false, fields: [] });
const [formLoadedFor, setFormLoadedFor] = useState<string>("");
const loadFormForEvent = 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 || '' })) });
} else {
setFormDef({ isRequired: false, fields: [] });
}
setFormLoadedFor(eventId);
} catch (e:any) {
setError(e?.message || 'Failed to load form');
} finally {
setFormLoading(false);
}
};
useEffect(() => {
if (mode === 'manage' && selectedEventId && selectedEventId !== formLoadedFor) {
loadFormForEvent(selectedEventId);
}
}, [mode, selectedEventId]);
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 }));
await apiFetch(`/api/events/${encodeURIComponent(selectedEventId)}`, { method: 'PUT', authToken: token, body: { form: { isRequired: !!formDef.isRequired, fields } } });
setInfo('Form saved');
} catch (e:any) {
setError(e?.message || 'Failed to save form');
}
};
// Fill/edit responses state
const [fillEventId, setFillEventId] = useState<string>("");
const [fillRegistrations, setFillRegistrations] = useState<any[]>([]);
const [loadingRegs, setLoadingRegs] = useState(false);
const [fillRegistrationId, setFillRegistrationId] = useState<string>("");
const [fillRegistration, setFillRegistration] = useState<any | null>(null);
const [fillForm, setFillForm] = useState<EventFormDef>({ isRequired: false, fields: [] });
const [existingResponses, setExistingResponses] = useState<any[]>([]);
const [savingFill, setSavingFill] = useState(false);
const loadRegsForEvent = async (evId: string) => {
if (!token || !evId) return;
try {
setLoadingRegs(true);
const regs = await apiFetch<any[]>(`/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 {
setFillRegistrations([]);
} finally {
setLoadingRegs(false);
}
};
useEffect(() => {
if (mode === 'fill') setFillEventId(prev => prev || selectedEventId || "");
}, [mode]);
useEffect(() => {
if (mode !== 'fill') return;
if (fillEventId) loadRegsForEvent(fillEventId);
setFillRegistrationId(""); setFillRegistration(null); setExistingResponses([]); setFillForm({ isRequired: false, fields: [] });
}, [fillEventId, mode]);
useEffect(() => {
(async () => {
if (mode !== 'fill' || !fillRegistrationId) return;
try {
const reg = await apiFetch<any>(`/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||'' })) });
} else {
setFillForm({ isRequired: false, fields: [] });
}
}
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);
} catch (e:any) {
setError(e?.message || 'Failed to load registration/form');
}
})();
}, [fillRegistrationId, mode]);
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);
}, [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 ans: Record<string,string> = {};
(r.answers || []).forEach((a:any)=>{ 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 };
});
const target = Math.max(0, mainTicketCount);
while (mapped.length < target) {
const ans: Record<string,string> = {};
for (const f of fields) ans[f.id!] = '';
mapped.push({ answers: ans });
}
if (mapped.length > target) mapped.length = target;
setEditableResponses(mapped);
}, [existingResponses, fillForm, mainTicketCount, mode]);
const setAnswer = (respIdx: number, fieldId: string, value: string) => {
setEditableResponses(prev => {
const copy = prev.slice();
if (!copy[respIdx]) copy[respIdx] = { answers: {} } as any;
copy[respIdx] = { answers: { ...copy[respIdx].answers, [fieldId]: value } };
return copy;
});
};
const saveResponses = async () => {
if (!token || !fillRegistrationId) return;
setError(null); setInfo(null);
try {
setSavingFill(true);
const requiredIds = (fillForm.fields||[]).filter(f=> f.type!== 'statement' && !!f.isRequired).map(f=> f.id);
for (let i=0; i<editableResponses.length; i++) {
const er = editableResponses[i];
for (const fid of requiredIds) {
const v = (er.answers || {})[fid!];
if (v == null || String(v).trim() === '') throw new Error(`Response #${i+1}: Missing answer for a required field`);
}
}
await apiFetch(`/api/registrations/${encodeURIComponent(fillRegistrationId)}/forms/responses`, {
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);
} catch (e:any) {
setError(e?.message || 'Failed to save responses');
} finally {
setSavingFill(false);
}
};
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4 no-print">
<h1 className="text-2xl font-semibold">Forms</h1>
<div className="flex gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={() => router.push("/dashboard")}>Back</button>
{mode === 'view' && (
<button className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" onClick={onPrint} disabled={items.length === 0}>
Print forms
</button>
)}
</div>
</div>
{!canView && (
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4 no-print">
You need staff, supervisor or admin access to use this page.
</div>
)}
{/* Mode tabs */}
<div className="mb-4 flex items-center gap-2 no-print">
{(['view', 'fill', 'manage'] as const).map(m => (
<label key={m} className={`px-3 py-1.5 text-sm rounded border cursor-pointer ${mode === m ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200 hover:bg-gray-50'}`}>
<input type="radio" name="mode" value={m} className="hidden" checked={mode===m} onChange={() => setMode(m)} />
{m === 'view' ? 'View responses' : m === 'fill' ? 'Fill / edit responses' : 'Edit form structure'}
</label>
))}
</div>
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm no-print">{error}</div>}
{info && <div className="p-3 mb-3 border rounded bg-emerald-50 text-emerald-800 text-sm no-print">{info}</div>}
{/* ── VIEW MODE ─────────────────────────────── */}
{mode === 'view' && (
<>
<div className="border rounded-xl p-4 bg-white shadow-sm mb-4 no-print">
<div className="text-base font-semibold mb-3">Filters</div>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-3 items-end">
<div className="lg:col-span-2">
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select
className="border rounded px-2 py-1.5 text-sm w-full"
value={selectedEventId}
onChange={e => setSelectedEventId(e.target.value)}
>
<option value="">All events</option>
{events.map(ev => (
<option key={ev.id} value={ev.id}>
{ev.title}{!ev.isActive ? ' (inactive)' : ''}{ev.endDate && new Date(ev.endDate) < new Date() ? ' (past)' : ''}
</option>
))}
</select>
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500">
<label className="flex items-center gap-1 cursor-pointer">
<input type="checkbox" checked={evIncludePast} onChange={e => setEvIncludePast(e.target.checked)} /> Include past events
</label>
{isAdmin && (
<label className="flex items-center gap-1 cursor-pointer">
<input type="checkbox" checked={evIncludeInactive} onChange={e => setEvIncludeInactive(e.target.checked)} /> Inactive events
</label>
)}
</div>
</div>
<div className="relative">
<label className="block text-xs text-gray-600 mb-1">User</label>
<input
className="border rounded px-2 py-1.5 text-sm w-full max-w-xs"
placeholder="Search by name, email or phone…"
value={userSearch}
onChange={e => { setUserSearch(e.target.value); setUserDropdownOpen(true); if (!e.target.value) setUserId(""); }}
onFocus={() => setUserDropdownOpen(true)}
onBlur={() => setTimeout(() => setUserDropdownOpen(false), 150)}
/>
{userId && (
<div className="text-xs text-indigo-600 mt-0.5 truncate max-w-xs">
{allUsers.find(u => u.id === userId)?.name || userId}
<button className="ml-1 text-gray-400 hover:text-gray-600" onMouseDown={e => { e.preventDefault(); setUserId(""); setUserSearch(""); }}></button>
</div>
)}
{userDropdownOpen && filteredUsers.length > 0 && (
<div className="absolute z-20 left-0 mt-1 w-full max-w-xs bg-white border rounded shadow-lg max-h-48 overflow-y-auto">
{filteredUsers.map(u => (
<button
key={u.id}
className="w-full text-left px-3 py-2 text-sm hover:bg-indigo-50 flex flex-col"
onMouseDown={e => { e.preventDefault(); setUserId(u.id); setUserSearch(u.name || u.email || u.id); setUserDropdownOpen(false); }}
>
<span className="font-medium truncate">{u.name || "(no name)"}</span>
<span className="text-xs text-gray-400 truncate">{u.email}</span>
</button>
))}
</div>
)}
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Registration ID</label>
<input className="border rounded px-2 py-1.5 text-sm w-full" placeholder="Paste registration ID…" value={registrationId} onChange={e => setRegistrationId(e.target.value)} />
</div>
</div>
<div className="flex gap-2 mt-3">
<button
className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700"
onClick={() => search(false)}
disabled={loadingList}
>
{loadingList ? "Searching…" : "Search"}
</button>
<button
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200"
onClick={() => { setSelectedEventId(""); setUserId(""); setUserSearch(""); setRegistrationId(""); setItems([]); setNextCursor(null); }}
>
Clear
</button>
{items.length > 0 && (
<span className="self-center text-xs text-gray-500">{items.length} response{items.length !== 1 ? 's' : ''} loaded</span>
)}
</div>
</div>
<div className="border rounded-xl bg-white shadow-sm">
{items.length === 0 ? (
<div className="p-6 text-sm text-gray-500 text-center">
{loadingList ? "Loading…" : "No form responses found. Select an event and search."}
</div>
) : (
<ul className="divide-y print-form-wrapper">
{items.map((r: any) => (
<li key={r.id} className="print-page-break">
<div className="p-4 print-response">
{/* Header */}
<div className="flex flex-wrap justify-between gap-2 mb-3 pb-2 border-b border-gray-200">
<div>
<div className="font-semibold text-gray-900">{r.registration?.event?.title || 'Event'}</div>
<div className="text-xs text-gray-500 mt-0.5">Registration #{r.registrationId?.slice(0, 8)}</div>
</div>
<div className="text-right">
<div className="font-medium text-gray-800">{r.registration?.user?.name || 'Attendee'}</div>
<div className="text-xs text-gray-500">{r.registration?.user?.email}</div>
<div className="text-xs text-gray-400">{new Date(r.createdAt).toLocaleString()}</div>
</div>
</div>
{/* Answers */}
{(!r.answers || r.answers.length === 0) ? (
<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) => (
<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>
</div>
))}
</div>
)}
</div>
</li>
))}
</ul>
)}
{nextCursor && (
<div className="p-3 border-t no-print">
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" disabled={loadingList} onClick={() => search(true)}>
Load more
</button>
</div>
)}
</div>
</>
)}
{/* ── MANAGE MODE ─────────────────────────────── */}
{mode === 'manage' && (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="text-base font-semibold mb-3">Edit form structure</div>
<div className="mb-4">
<label className="block text-xs text-gray-600 mb-1">Select event</label>
<select
className="border rounded px-2 py-1.5 text-sm w-full sm:max-w-sm"
value={selectedEventId}
onChange={e => { setSelectedEventId(e.target.value); setFormLoadedFor(""); }}
>
<option value="">Choose an event</option>
{allEvents.map(ev => (
<option key={ev.id} value={ev.id}>{ev.title}{!ev.isActive ? ' (inactive)' : ''}</option>
))}
</select>
<div className="text-xs text-gray-400 mt-1">All events shown (including inactive/past) so you can edit historical forms.</div>
{formLoading && <span className="text-xs text-gray-400 mt-1 block">Loading form</span>}
</div>
{!selectedEventId ? (
<div className="text-sm text-gray-500">Select an event above to manage its attendee form.</div>
) : (
<>
<FormBuilder value={formDef} onChange={setFormDef} />
<div className="mt-3 flex gap-2">
<button
type="button"
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
onClick={saveForm}
disabled={formLoading}
>
Save form
</button>
<button
type="button"
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200"
onClick={() => loadFormForEvent(selectedEventId)}
disabled={formLoading}
>
Reload
</button>
</div>
</>
)}
</div>
)}
{/* ── FILL MODE ─────────────────────────────── */}
{mode === 'fill' && (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="text-base font-semibold mb-3">Fill / edit responses</div>
<div className="grid sm:grid-cols-2 gap-3 mb-4 items-end">
<div>
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select className="border rounded px-2 py-1.5 text-sm w-full" value={fillEventId} onChange={e => setFillEventId(e.target.value)}>
<option value="">Select event</option>
{allEvents.map(ev => (<option key={ev.id} value={ev.id}>{ev.title}</option>))}
</select>
</div>
<div>
<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)=>{
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>;
})}
</select>
</div>
</div>
{!fillRegistrationId ? (
<div className="text-sm text-gray-500">Choose an event and registration to fill responses.</div>
) : fillForm.fields.length === 0 ? (
<div className="text-sm text-gray-500">This event has no form configured. Go to &ldquo;Edit form structure&rdquo; to add fields.</div>
) : (
<div className="space-y-4">
<div className="text-sm text-gray-600">
Main tickets: <span className="font-medium">{mainTicketCount}</span>
{mainTicketCount === 0 && <span className="ml-2 text-amber-600 text-xs">(No main tickets found check registration options)</span>}
</div>
{editableResponses.map((resp, idx) => (
<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)=> (
<div key={f.id}>
<label className="block text-xs text-gray-600 mb-1">{f.label}{f.isRequired ? ' *' : ''}</label>
{f.type === 'text' && (
<input className="border rounded px-2 py-1 text-sm w-full" value={resp.answers[f.id]||''} onChange={e=> setAnswer(idx, f.id!, e.target.value)} />
)}
{f.type === 'numeric' && (
<input type="number" className="border rounded px-2 py-1 text-sm w-full" value={resp.answers[f.id]||''} onChange={e=> setAnswer(idx, f.id!, e.target.value)} />
)}
{f.type === 'date' && (
<input type="date" className="border rounded px-2 py-1 text-sm w-full" value={resp.answers[f.id]||''} onChange={e=> setAnswer(idx, f.id!, e.target.value)} />
)}
{f.type === 'yes_no' && (
<select className="border rounded px-2 py-1 text-sm w-full" value={resp.answers[f.id]||''} onChange={e=> setAnswer(idx, f.id!, e.target.value)}>
<option value="">Select</option>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
)}
{f.helpText && <div className="text-[10px] text-gray-500 mt-0.5">{f.helpText}</div>}
</div>
))}
</div>
</div>
))}
<div className="flex gap-2">
<button
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
onClick={saveResponses}
disabled={savingFill || mainTicketCount === 0}
>
{savingFill ? 'Saving…' : 'Save responses'}
</button>
</div>
</div>
)}
</div>
)}
</div>
);
}