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>
735 lines
36 KiB
TypeScript
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 “Edit form structure” 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>
|
|
);
|
|
} |