Initial commit

Next.js + Express event management app for Hope Family Church.
This commit is contained in:
2026-07-23 15:26:47 +02:00
commit 3d381944d2
246 changed files with 57565 additions and 0 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,297 @@
"use client";
import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch } from "@/lib/api";
// Format a Date (or date-like input) to the value expected by <input type="datetime-local">
// This returns local time (browser timezone) as YYYY-MM-DDTHH:mm
function toLocalDateTimeInputValue(input: string | number | Date | null | undefined): string {
if (input == null) return "";
const d = new Date(input);
if (isNaN(d.getTime())) return "";
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
return `${y}-${m}-${day}T${hh}:${mm}`;
}
function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers: { deadline: string; price: number; order?: number }[]) => void }) {
const [rows, setRows] = React.useState<{ id?: string; deadline: string; price: string; order?: number }[]>([]);
const [open, setOpen] = React.useState(false);
const [saving, setSaving] = React.useState(false);
React.useEffect(() => {
const tiers = Array.isArray(option?.earlyBirdTiers) ? option.earlyBirdTiers : [];
const normalized = tiers
.slice()
.sort((a: any, b: any) => new Date(a.deadline).getTime() - new Date(b.deadline).getTime() || (a.order || 0) - (b.order || 0))
.map((t: any, i: number) => ({ id: t.id, deadline: toLocalDateTimeInputValue(t.deadline), price: String(t.price ?? ''), order: typeof t.order === 'number' ? t.order : i }));
setRows(normalized);
}, [option?.id, option?.earlyBirdTiers]);
const addRow = () => {
setRows((r) => [...r, { deadline: '', price: '', order: (r.length || 0) }]);
};
const removeRow = (idx: number) => {
const copy = rows.slice();
copy.splice(idx, 1);
setRows(copy);
};
const updateRow = (idx: number, patch: Partial<{ deadline: string; price: string; order?: number }>) => {
const copy = rows.slice();
copy[idx] = { ...copy[idx], ...patch } as any;
setRows(copy);
};
const save = async () => {
setSaving(true);
try {
const tiers = rows
.filter((r) => !!r.deadline && String(r.price).trim() !== '')
.map((r, i) => ({ deadline: new Date(r.deadline).toISOString(), price: parseFloat(r.price), order: typeof r.order === 'number' ? r.order : i }))
.filter((t) => t.price >= 0 && !isNaN(new Date(t.deadline).getTime()));
onSave(tiers);
} finally {
setSaving(false);
}
};
return (
<div className="mt-3 border-t pt-3">
<button type="button" className="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200" onClick={() => setOpen(!open)}>
{open ? 'Hide early-bird tiers' : 'Manage early-bird tiers'}
</button>
{open && (
<div className="mt-2 bg-gray-50 border rounded p-2">
{rows.length === 0 ? (
<div className="text-xs text-gray-600 mb-2">No tiers yet. Add deadlines and prices for early-bird discounts.</div>
) : (
<ul className="space-y-2 mb-2">
{rows.map((row, idx) => (
<li key={idx} className="bg-white border rounded p-2">
<div className="flex flex-wrap items-center gap-2">
<input className="border rounded px-2 py-1 text-xs" type="datetime-local" value={row.deadline} onChange={(e) => updateRow(idx, { deadline: e.target.value })} />
<input className="border rounded px-2 py-1 text-xs w-24" type="number" step="0.01" value={row.price} onChange={(e) => updateRow(idx, { price: e.target.value })} placeholder="Price" />
<input className="border rounded px-2 py-1 text-xs w-16" type="number" step="1" value={typeof row.order === 'number' ? String(row.order) : ''} onChange={(e) => updateRow(idx, { order: parseInt(e.target.value || '0', 10) })} placeholder="#" />
<button type="button" className="text-xs px-2 py-1 rounded bg-red-600 text-white hover:bg-red-700" onClick={() => removeRow(idx)}>Remove</button>
</div>
</li>
))}
</ul>
)}
<div className="flex items-center gap-2">
<button type="button" className="text-xs px-2 py-1 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={addRow}>Add tier</button>
<button type="button" className="text-xs px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-60" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save tiers'}</button>
</div>
</div>
)}
</div>
);
}
function EventOptionsContent() {
const { user, loading, token } = useAuth();
const router = useRouter();
const search = useSearchParams();
const canView = useMemo(() => {
const role = user?.role;
return role === "admin" || role === "supervisor";
}, [user]);
useEffect(() => {
if (loading) return;
if (!user) router.replace("/login");
}, [user, loading, router]);
const [events, setEvents] = useState<any[]>([]);
const [selectedEventId, setSelectedEventId] = useState<string>("");
const [options, setOptions] = useState<any[]>([]);
const [loadingEv, setLoadingEv] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const loadEvents = async () => {
if (!token) return;
try {
setLoadingEv(true);
const evs = await apiFetch<any[]>("/api/events/all", { authToken: token });
setEvents(evs || []);
} catch (e) {
// ignore
} finally {
setLoadingEv(false);
}
};
useEffect(() => { loadEvents(); }, [token]);
// Preselect event from query (?eventId=)
useEffect(() => {
const q = search?.get("eventId");
if (!q) return;
// if events already loaded, ensure it exists then set; otherwise, set directly and let options hook handle
setSelectedEventId(q);
}, [search]);
useEffect(() => {
const ev = events.find(e => e.id === selectedEventId);
if (ev) {
setOptions(ev.options || ev.eventOptions || []);
} else {
setOptions([]);
}
}, [selectedEventId, events]);
const [newOpt, setNewOpt] = useState({ name: "", price: "", isMainTicket: false });
const createOption = async () => {
if (!token || !selectedEventId) return;
setError(null); setInfo(null);
if (!newOpt.name) { setError("Option name is required"); return; }
const priceNum = parseFloat(newOpt.price || "0");
try {
await apiFetch(`/api/events/${encodeURIComponent(selectedEventId)}/options`, {
method: "POST",
authToken: token,
body: { name: newOpt.name, price: priceNum || 0, isMainTicket: !!newOpt.isMainTicket }
});
setNewOpt({ name: "", price: "", isMainTicket: false });
setInfo("Option created");
await loadEvents();
} catch (e: any) {
setError(e?.message || "Failed to create option");
}
};
const updateOption = async (opt: any, patch: any) => {
if (!token) return;
setError(null); setInfo(null);
try {
await apiFetch(`/api/events/options/${encodeURIComponent(opt.id)}`, {
method: "PUT",
authToken: token,
body: patch
});
setInfo("Option updated");
await loadEvents();
} catch (e: any) {
setError(e?.message || "Failed to update option");
}
};
const deleteOption = async (opt: any) => {
if (!token) return;
setError(null); setInfo(null);
try {
await apiFetch(`/api/events/options/${encodeURIComponent(opt.id)}`, {
method: "DELETE",
authToken: token,
});
setInfo("Option deleted");
await loadEvents();
} catch (e: any) {
setError(e?.message || "Failed to delete option");
}
};
return (
<div className="max-w-5xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Event options</h1>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard/supervisor/events')}>Back</button>
</div>
{!canView && (
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
You need supervisor or admin access to use this page.
</div>
)}
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
{info && <div className="p-3 mb-3 border rounded bg-emerald-50 text-emerald-800 text-sm">{info}</div>}
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
<div className="text-lg font-semibold mb-3">Select event</div>
<select className="w-full border rounded px-3 py-2 text-sm" value={selectedEventId} onChange={e => setSelectedEventId(e.target.value)}>
<option value="">Select an event</option>
{events.map(ev => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
{selectedEventId && (
<div className="mt-3 text-xs text-gray-600">
{(() => {
const ev = events.find(e => e.id === selectedEventId);
if (!ev) return null;
return <>
<div>{new Date(ev.startDate).toLocaleString()} - {new Date(ev.endDate).toLocaleString()}</div>
</>;
})()}
</div>
)}
</div>
{selectedEventId && (
<div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 border rounded-xl p-4 bg-white shadow-sm">
<div className="text-lg font-semibold mb-3">Options</div>
{options.length === 0 ? (
<div className="text-sm text-gray-500">No options yet.</div>
) : (
<ul className="space-y-3">
{options.map(opt => (
<li key={opt.id} className="border rounded p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-medium text-sm">{opt.name}</div>
<div className="text-xs text-gray-500">Price: R {(opt.price || 0).toFixed(2)} {opt.isMainTicket ? "• Main ticket" : ""}</div>
</div>
<div className="flex items-center gap-2">
<input className="w-36 border rounded px-2 py-1 text-sm" defaultValue={opt.name} onBlur={e => { const v = e.target.value.trim(); if (v && v !== opt.name) updateOption(opt, { name: v }); }} />
<input className="w-28 border rounded px-2 py-1 text-sm" type="number" step="0.01" defaultValue={(opt.price || 0)} onBlur={e => { const v = parseFloat(e.target.value || '0'); if (!isNaN(v) && v !== opt.price) updateOption(opt, { price: v }); }} />
<label className="text-xs flex items-center gap-1"><input type="checkbox" defaultChecked={!!opt.isMainTicket} onChange={e => updateOption(opt, { isMainTicket: e.target.checked })} /> Main</label>
{user?.role === 'admin' && (
<button onClick={() => deleteOption(opt)} className="text-xs px-2 py-1 rounded bg-red-600 text-white hover:bg-red-700">
Delete
</button>
)}
</div>
</div>
{/* Early Bird Tiers Editor */}
<EarlyBirdTiersEditor
option={opt}
onSave={(tiers) => updateOption(opt, { earlyBirdTiers: tiers })}
/>
</li>
))}
</ul>
)}
</div>
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="text-lg font-semibold mb-3">Create new option</div>
<div className="grid gap-2">
<input className="border rounded px-3 py-2 text-sm" placeholder="Name" value={newOpt.name} onChange={e => setNewOpt({ ...newOpt, name: e.target.value })} />
<input className="border rounded px-3 py-2 text-sm" placeholder="Price" type="number" step="0.01" value={newOpt.price} onChange={e => setNewOpt({ ...newOpt, price: e.target.value })} />
<label className="text-sm flex items-center gap-2"><input type="checkbox" checked={newOpt.isMainTicket} onChange={e => setNewOpt({ ...newOpt, isMainTicket: e.target.checked })} /> Main ticket</label>
<button onClick={createOption} className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm">Create option</button>
</div>
</div>
</div>
)}
</div>
);
}
export default function EventOptionsPage() {
return (
<Suspense fallback={<div className="p-6">Loading...</div>}>
<EventOptionsContent />
</Suspense>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,734 @@
"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";
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] = useState<string | null>(null);
const [info, setInfo] = useState<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>
);
}
@@ -0,0 +1,203 @@
"use client";
import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null };
export default function ManualRegistrationPage() {
const { user, token, loading } = useAuth();
const router = useRouter();
const [eventId, setEventId] = useState("");
const [optionId, setOptionId] = useState("");
const [quantity, setQuantity] = useState(1);
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [phoneNumber, setPhoneNumber] = useState("");
const [registerAsGuest, setRegisterAsGuest] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [createdReg, setCreatedReg] = useState<any | null>(null);
const [form, setForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
const [formsData, setFormsData] = useState<Record<number, Record<string, string>>>({});
useEffect(() => {
if (loading) return;
if (!user) router.replace("/login");
}, [user, loading, router]);
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!token) return;
setBusy(true);
setError(null);
try {
const res = await apiFetch<any>("/api/registrations/manual", {
method: "POST",
authToken: token,
body: {
eventId,
options: [{ eventOptionId: optionId, quantity }],
user: { name, ...(email ? { email } : {}), ...(phoneNumber ? { phoneNumber } : {}) },
guestOnly: registerAsGuest,
},
});
setCreatedReg(res);
// Load form definition for this event (if any)
try {
const ev = await apiFetch<any>(`/api/events/${encodeURIComponent(eventId)}`);
if (ev?.form) setForm(ev.form);
} catch {}
alert("Manual registration created");
} catch (e: any) {
setError(e?.message || "Failed to create manual registration");
} finally {
setBusy(false);
}
}
return (
<div className="max-w-xl">
<h1 className="text-xl font-semibold mb-4">Manual Registration</h1>
<form onSubmit={submit} className="space-y-3">
<div>
<label className="block text-sm font-medium">Event ID</label>
<input className="w-full border rounded px-3 py-2" value={eventId} onChange={(e) => setEventId(e.target.value)} required />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium">Option ID</label>
<input className="w-full border rounded px-3 py-2" value={optionId} onChange={(e) => setOptionId(e.target.value)} required />
</div>
<div>
<label className="block text-sm font-medium">Quantity</label>
<input type="number" min={1} className="w-full border rounded px-3 py-2" value={quantity} onChange={(e) => setQuantity(parseInt(e.target.value || "1", 10))} required />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium">Name</label>
<input className="w-full border rounded px-3 py-2" value={name} onChange={(e) => setName(e.target.value)} required />
</div>
<div>
<div className="flex items-center justify-between">
<label className="block text-sm font-medium">Email</label>
<label className="text-xs flex items-center gap-2"><input type="checkbox" checked={registerAsGuest} onChange={e=>setRegisterAsGuest(e.target.checked)} /> Guest (no account)</label>
</div>
<input type="email" className="w-full border rounded px-3 py-2" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="email@example.com" />
</div>
</div>
<div>
<label className="block text-sm font-medium">Cell Number</label>
<input type="tel" className="w-full border rounded px-3 py-2" value={phoneNumber} onChange={(e) => setPhoneNumber(e.target.value)} placeholder="+27…" />
</div>
<p className="text-xs text-gray-500">At least one of email or cell number is required. If no email is provided, a guest account is created automatically.</p>
{error && <p className="text-sm text-red-600">{error}</p>}
<button type="submit" disabled={busy} className="bg-blue-600 text-white rounded px-4 py-2 disabled:opacity-60">
{busy ? "Submitting..." : "Create"}
</button>
</form>
{createdReg && form && Array.isArray(form.fields) && (
<AttendeeFormsSection registration={createdReg} form={form} formsData={formsData} setFormsData={setFormsData} />
)}
</div>
);
}
function AttendeeFormsSection({ registration, form, formsData, setFormsData }: { registration: any; form: { isRequired: boolean; fields: FormField[] }; formsData: Record<number, Record<string,string>>; setFormsData: any; }) {
const { token } = useAuth();
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const mainTickets = (registration?.registrationOptions || []).filter((o: any) => o?.eventOption?.isMainTicket).reduce((s: number, o: any) => s + (o.quantity || 0), 0);
const count = Math.max(0, mainTickets);
const canSubmit = React.useMemo(() => {
if (!form || count <= 0) return false;
const reqFields = (form.fields || [])
.filter(f => !!f.isRequired && f.type !== 'statement' && f.type !== 'paragraph')
.map(f => f.id);
for (let i = 0; i < count; i++) {
const data = formsData[i] || {};
for (const fid of reqFields) {
const v = data[fid];
if (v === undefined || v === null || String(v).trim() === '') {
return false;
}
}
}
return true;
}, [form, formsData, count]);
const update = (idx: number, fieldId: string, value: string) => {
setFormsData((prev: any) => ({ ...prev, [idx]: { ...(prev[idx]||{}), [fieldId]: value } }));
};
const submit = async () => {
if (!token) return;
try {
setSubmitting(true);
setError(null); setInfo(null);
const payload = [] as any[];
for (let i = 0; i < count; i++) payload.push({ answers: formsData[i] || {} });
await apiFetch(`/api/registrations/${encodeURIComponent(registration.id)}/forms/responses`, {
method: 'POST', authToken: token, body: { responses: payload }
});
setInfo('Attendee forms submitted successfully.');
} catch (e: any) {
setError(e?.message || 'Failed to submit forms');
} finally {
setSubmitting(false);
}
};
if (!form || !Array.isArray(form.fields) || count === 0) return null;
return (
<div className="mt-6 border rounded p-3 bg-gray-50">
<div className="text-sm font-medium mb-2">Attendee forms for this registration</div>
{error && <div className="text-xs text-red-600 mb-2">{error}</div>}
{info && <div className="text-xs text-emerald-700 mb-2">{info}</div>}
<div className="space-y-4">
{Array.from({ length: count }, (_, idx) => (
<div key={idx} className="bg-white border rounded p-3">
<div className="font-medium mb-2">Attendee {idx + 1}</div>
{form.fields.map((f) => (
<div key={f.id} className="mb-2">
{f.type === 'statement' ? (
<div className="text-sm text-gray-700 whitespace-pre-line">{f.label}</div>
) : f.type === 'paragraph' ? (
<div className="text-sm text-gray-700">
{f.label && <div className="font-medium mb-1 whitespace-pre-line">{f.label}</div>}
{f.helpText && <div className="whitespace-pre-line">{f.helpText}</div>}
</div>
) : (
<>
<label className="block text-xs text-gray-600 mb-1">{f.label}{f.isRequired ? ' *' : ''}</label>
{f.type === 'yes_no' ? (
<select className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => update(idx, f.id, e.target.value)}>
<option value="">Select</option>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
) : f.type === 'date' ? (
<input type="date" className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => update(idx, f.id, e.target.value)} />
) : f.type === 'numeric' ? (
<input type="number" className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => update(idx, f.id, e.target.value)} />
) : (
<input type="text" className="border rounded px-2 py-1 text-sm w-full" value={formsData[idx]?.[f.id] || ''} onChange={e => update(idx, f.id, e.target.value)} />
)}
{f.helpText && <div className="text-xs text-gray-500 mt-1">{f.helpText}</div>}
</>
)}
</div>
))}
</div>
))}
</div>
<button className="mt-3 px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50" disabled={submitting || !canSubmit} onClick={submit}>{submitting ? 'Submitting…' : 'Submit forms'}</button>
</div>
);
}
@@ -0,0 +1,451 @@
"use client";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch";
// ─── 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;
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;
}
function effectiveVariantUnit(opt: any, variant: any): 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);
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;
}
function fmtPrice(n: number) {
return n === 0 ? "Free" : `R ${n.toFixed(2)}`;
}
// ─── Component ───────────────────────────────────────────────────────────────
export default function ManualRegistrationPage() {
const { user, loading, token } = useAuth();
const router = useRouter();
const canView = useMemo(() => {
const role = user?.role;
return role === "admin" || role === "supervisor";
}, [user]);
useEffect(() => {
if (loading) return;
if (!user) router.replace("/login");
}, [user, loading, router]);
const [events, setEvents] = useState<any[]>([]);
const [selectedEventId, setSelectedEventId] = useState<string>("");
const [options, setOptions] = useState<any[]>([]);
// All system users for fuzzy lookup
const [allUsers, setAllUsers] = useState<any[]>([]);
const [guest, setGuest] = useState({ name: "", email: "", phoneNumber: "" });
const [registerAsGuest, setRegisterAsGuest] = useState(false);
const [notifPref, setNotifPref] = useState<"email" | "whatsapp" | "both">("email");
const [quantities, setQuantities] = useState<Record<string, number>>({});
// User search state
const [userQuery, setUserQuery] = useState("");
const [dropdownOpen, setDropdownOpen] = useState(false);
const searchRef = useRef<HTMLDivElement>(null);
const [submitting, setSubmitting] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// Load all users for client-side fuzzy matching
useEffect(() => {
if (!token) return;
fetchAllUsers(token)
.then(users => setAllUsers(users))
.catch(() => {});
}, [token]);
// Fuzzy match results (top 6, score threshold 0.45)
const matchedUsers = useMemo(() => {
if (userQuery.trim().length < 2) return [];
return allUsers
.map(u => ({ u, score: scoreUser(u, userQuery) }))
.filter(x => x.score >= 0.45)
.sort((a, b) => b.score - a.score)
.slice(0, 6)
.map(x => x.u);
}, [userQuery, allUsers]);
const selectUser = (u: any) => {
setGuest({ name: u.name || "", email: u.email || "", phoneNumber: u.phoneNumber || "" });
setUserQuery(u.name || "");
setDropdownOpen(false);
};
// Close dropdown on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
setDropdownOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
useEffect(() => {
(async () => {
try {
if (!token) return;
const evs = await apiFetch<any[]>("/api/events/all", { authToken: token });
const now = Date.now();
const active = (evs || []).filter(ev => {
const t = new Date(ev.endDate).getTime();
// Manual registration is rejected server-side for closed (cashed-up) events —
// don't offer them here even in the rare case one is closed before it ends.
return !isNaN(t) && t > now && ev.cashupStatus !== 'closed';
});
active.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
setEvents(active);
} catch (e: any) {
// ignore
}
})();
}, [token]);
useEffect(() => {
const ev = events.find(e => e.id === selectedEventId);
if (ev) {
const opts = ev.options || ev.eventOptions || [];
setOptions(opts);
const map: Record<string, number> = {};
opts.forEach((o: any) => {
if ((o.variants || []).length > 0) {
(o.variants as any[]).forEach(v => { map[`${o.id}::${v.id}`] = 0; });
} else {
map[o.id] = 0;
}
});
setQuantities(map);
} else {
setOptions([]);
setQuantities({});
}
}, [selectedEventId, events]);
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 + (quantities[o.id] || 0) * effectiveOptionUnit(o);
}, 0);
}, [options, quantities]);
const submit = async () => {
if (!token) return;
setError(null);
setMessage(null);
if (!selectedEventId) { setError("Please select an event."); return; }
if (!guest.name || (!registerAsGuest && !guest.email)) { setError("Guest name and email are required."); return; }
const opts = Object.entries(quantities)
.filter(([, qty]) => qty > 0)
.map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}) };
});
if (opts.length === 0) { setError("Please select at least one ticket option."); return; }
try {
setSubmitting(true);
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", {
method: "POST",
authToken: token,
body: {
eventId: selectedEventId,
options: opts,
user: guest,
guestOnly: registerAsGuest,
notificationPreference: resolvedPref,
}
});
setMessage("Manual registration created successfully.");
// Reset guest/ticket fields so the next registration starts from a clean slate
setGuest({ name: "", email: "", phoneNumber: "" });
setRegisterAsGuest(false);
setNotifPref("email");
setUserQuery("");
setDropdownOpen(false);
setQuantities(prev => Object.fromEntries(Object.keys(prev).map(k => [k, 0])));
} catch (e: any) {
setError(e?.message || "Failed to create manual registration");
} finally {
setSubmitting(false);
}
};
return (
<div className="max-w-4xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Manual registration</h1>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
</div>
{!canView && (
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
You need supervisor or admin access to use this page.
</div>
)}
{message && <div className="p-3 mb-3 border rounded bg-emerald-50 text-emerald-800 text-sm">{message}</div>}
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
<div className="grid md:grid-cols-2 gap-6">
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="text-lg font-semibold mb-3">1) Choose event</div>
<select className="w-full border rounded px-3 py-2 text-sm" value={selectedEventId} onChange={e => setSelectedEventId(e.target.value)}>
<option value="">Select an event</option>
{events.map(ev => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
{selectedEventId && (
<div className="mt-3 text-xs text-gray-600">
{(() => {
const ev = events.find(e => e.id === selectedEventId);
if (!ev) return null;
return <>
<div>{new Date(ev.startDate).toLocaleString()} - {new Date(ev.endDate).toLocaleString()}</div>
</>;
})()}
</div>
)}
</div>
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="text-lg font-semibold mb-3">2) Guest details</div>
{/* ── User lookup ─────────────────────────────────────────── */}
<div ref={searchRef} className="relative mb-4">
<label className="block text-xs font-medium text-gray-500 mb-1">
Search existing user <span className="font-normal">(name, email or phone)</span>
</label>
<input
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
placeholder="Start typing to find a user…"
value={userQuery}
autoComplete="off"
onChange={e => { setUserQuery(e.target.value); setDropdownOpen(true); }}
onFocus={() => { if (userQuery.length >= 2) setDropdownOpen(true); }}
/>
{dropdownOpen && userQuery.trim().length >= 2 && (
<div className="absolute z-30 top-full left-0 right-0 mt-1 bg-white border border-gray-200 rounded-xl shadow-lg overflow-hidden">
{matchedUsers.length > 0 ? (
<>
<div className="px-3 py-1.5 text-[11px] text-gray-400 bg-gray-50 border-b">
{matchedUsers.length} match{matchedUsers.length !== 1 ? "es" : ""} click to auto-fill
</div>
{matchedUsers.map(u => (
<button
key={u.id}
type="button"
className="w-full text-left px-3 py-2.5 hover:bg-indigo-50 border-b border-gray-100 last:border-b-0 transition-colors"
onClick={() => selectUser(u)}
>
<div className="text-sm font-medium text-gray-900">{u.name}</div>
<div className="text-xs text-gray-500 mt-0.5 flex gap-2 flex-wrap">
{u.email && <span>{u.email}</span>}
{u.phoneNumber && <span>· {u.phoneNumber}</span>}
</div>
</button>
))}
</>
) : (
<div className="px-3 py-3 text-sm text-gray-500 italic">
No matching users found fill in details below manually.
</div>
)}
</div>
)}
</div>
<div className="border-t pt-3 mb-3">
<div className="flex items-center gap-2">
<input id="registerAsGuest" type="checkbox" checked={registerAsGuest} onChange={e => setRegisterAsGuest(e.target.checked)} />
<label htmlFor="registerAsGuest" className="text-sm text-gray-700">Guest (do not link to an existing account)</label>
</div>
</div>
<div className="grid gap-2">
<input
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
placeholder="Full name"
value={guest.name}
onChange={e => setGuest({ ...guest, name: e.target.value })}
/>
<input
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
placeholder={registerAsGuest ? "Email (optional for guest)" : "Email"}
type="email"
value={guest.email}
onChange={e => setGuest({ ...guest, email: e.target.value })}
required={!registerAsGuest}
/>
<input
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
placeholder="Phone (optional)"
value={guest.phoneNumber}
onChange={e => {
const v = e.target.value;
setGuest({ ...guest, phoneNumber: v });
if (v.trim() && !guest.email.trim()) setNotifPref("whatsapp");
else if (!v.trim() && guest.email.trim()) setNotifPref("email");
}}
/>
{/* Preference selector */}
{(() => {
const hasEmail = !!guest.email.trim();
const hasPhone = !!guest.phoneNumber.trim();
if (!hasEmail && !hasPhone) return null;
if (hasEmail && !hasPhone) return (
<p className="text-xs text-gray-500">Tickets will be sent via <strong>email</strong>.</p>
);
if (hasPhone && !hasEmail) return (
<p className="text-xs text-gray-500">Tickets will be sent via <strong>WhatsApp</strong>.</p>
);
return (
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Send tickets via</label>
<div className="flex rounded-lg border overflow-hidden text-xs font-medium">
{(["email", "whatsapp", "both"] as const).map((p) => (
<button
key={p}
type="button"
onClick={() => setNotifPref(p)}
className={`flex-1 py-2 transition-colors ${
notifPref === p
? p === "whatsapp" ? "bg-green-600 text-white border-green-600"
: p === "both" ? "bg-indigo-600 text-white"
: "bg-blue-600 text-white"
: "bg-white text-gray-600 hover:bg-gray-50"
}`}
>
{p === "email" ? "Email" : p === "whatsapp" ? "WhatsApp" : "Both"}
</button>
))}
</div>
</div>
);
})()}
{guest.name && (
<button
type="button"
onClick={() => { setGuest({ name: "", email: "", phoneNumber: "" }); setUserQuery(""); setNotifPref("email"); }}
className="text-xs text-gray-400 hover:text-gray-600 text-left"
>
Clear
</button>
)}
</div>
</div>
</div>
<div className="mt-6 border rounded-xl p-4 bg-white shadow-sm">
<div className="text-lg font-semibold mb-3">3) Select ticket options</div>
{options.length === 0 ? (
<div className="text-sm text-gray-500">Select an event to view options.</div>
) : (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
{options.map(opt => {
const hasVariants = (opt.variants || []).length > 0;
if (hasVariants) {
return (
<div key={opt.id} className="border rounded overflow-hidden col-span-full sm:col-span-1">
<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-blue-600 font-normal"> Main</span> : null}
</div>
{(opt.variants as any[]).map((v: any) => {
const unit = effectiveVariantUnit(opt, v);
const basePrice = v.price !== null && v.price !== undefined ? v.price : opt.price;
const key = `${opt.id}::${v.id}`;
return (
<div key={v.id} className="flex items-center justify-between px-3 py-2 border-b last:border-b-0">
<div>
<div className="text-sm">{v.name}</div>
<div className="text-xs text-gray-500">
{fmtPrice(unit)}
{unit < basePrice && basePrice > 0 && <span className="ml-1 text-green-600">(early bird, was {fmtPrice(basePrice)})</span>}
</div>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-gray-500">Qty</label>
<input
type="number" min={0}
className="w-16 border rounded px-2 py-1 text-sm"
value={quantities[key] || 0}
onChange={e => setQuantities(q => ({ ...q, [key]: Math.max(0, parseInt(e.target.value || '0')) }))}
/>
</div>
</div>
);
})}
</div>
);
}
const unit = effectiveOptionUnit(opt);
return (
<div key={opt.id} className="border rounded p-3">
<div className="font-medium text-sm">{opt.name}</div>
<div className="text-xs text-gray-500">
{fmtPrice(unit)}
{unit < opt.price && opt.price > 0 && <span className="ml-1 text-green-600">(early bird, was {fmtPrice(opt.price)})</span>}
{opt.isMainTicket ? " • Main" : ""}
</div>
<div className="flex items-center gap-2 mt-2">
<label className="text-xs text-gray-600">Qty</label>
<input
type="number" min={0}
className="w-20 border rounded px-2 py-1 text-sm"
value={quantities[opt.id] || 0}
onChange={e => setQuantities(q => ({ ...q, [opt.id]: Math.max(0, parseInt(e.target.value || '0')) }))}
/>
</div>
</div>
);
})}
</div>
)}
</div>
<div className="flex items-center justify-between mt-6">
<div className="text-sm">Total due: <span className="font-semibold">R {totalDue.toFixed(2)}</span></div>
<button disabled={submitting} onClick={submit} className="px-4 py-2 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 shadow-sm">{submitting ? 'Creating…' : 'Create registration'}</button>
</div>
</div>
);
}
@@ -0,0 +1,215 @@
"use client";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
export default function SupervisorDashboardPage() {
const { user, loading, token } = useAuth();
const router = useRouter();
const canView = useMemo(() => {
const role = user?.role;
return role === "admin" || role === "supervisor";
}, [user]);
useEffect(() => {
if (loading) return;
if (!user) router.replace("/login");
}, [user, loading, router]);
// Everything this dashboard displays comes from one endpoint (/api/stats/supervisor)
// that computes it all server-side — no more separate calls plus a full payments/events
// pull just to reduce them down to a couple of numbers client-side.
// useStableState skips re-renders when a poll returns identical data, and hasLoadedOnce
// below means "Refreshing…" only shows on the very first load — together these stop the
// stats panels from flickering on every 15s poll.
const [scanStats, setScanStats] = useStableState<any | null>(null);
const [paymentStats, setPaymentStats] = useStableState<any | null>(null);
const [activeEventsCount, setActiveEventsCount] = useStableState<number>(0);
const [recentScans, setRecentScans] = useStableState<any[]>([]);
const [loadingStats, setLoadingStats] = useState(false);
const hasLoadedOnce = useRef(false);
const loadStats = async () => {
if (!token) return;
const isFirstLoad = !hasLoadedOnce.current;
try {
if (isFirstLoad) setLoadingStats(true);
const data = await apiFetch<any>("/api/stats/supervisor", { authToken: token });
setScanStats(data.scanStats);
setPaymentStats(data.paymentStats);
setActiveEventsCount(data.activeEventsCount || 0);
setRecentScans(Array.isArray(data.recentScans) ? data.recentScans : []);
} catch (e) {
// ignore
} finally {
hasLoadedOnce.current = true;
if (isFirstLoad) setLoadingStats(false);
}
};
useEffect(() => {
if (!token) return;
loadStats();
}, [token]);
// Poll every 15s while the tab is visible; pause in the background and refetch
// immediately on return instead of leaving stale numbers up.
useVisiblePolling(() => {
if (!token) return;
loadStats();
}, 15000, !!token);
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Supervisor Dashboard{user ? `${user.name}` : ""}</h1>
<div className="hidden sm:flex gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/manual")}>Manual registration</button>
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/events")}>Manage events</button>
<button className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/payments")}>Payments</button>
</div>
</div>
{!canView && (
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
You need supervisor or admin access to use these tools.
</div>
)}
<div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
<div className="text-lg font-semibold mb-2">Quick actions</div>
<div className="grid sm:grid-cols-3 gap-3">
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/manual")}>Create manual registration
<div className="text-xs text-white/90">Register a guest and issue tickets</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/events")}>Manage events
<div className="text-xs text-white/90">Create, edit, and update ticket types</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/sections")}>Manage sections
<div className="text-xs text-white/90">Create sections and assign ticket types</div>
</button>
<button className="rounded-lg p-3 text-left bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/payments")}>Record payment / donations
<div className="text-xs text-white/90">Manual payments and assignment</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/ticket-scanning")}>Open scanner
<div className="text-xs text-white/90">Use your device camera to validate tickets</div>
</button>
<button className="rounded-lg p-3 text-left bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/event-tickets")}>Event tickets & printing
<div className="text-xs text-white/90">Browse event tickets and print lists</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/at-the-door")}>At the door
<div className="text-xs text-white/90">Walk-ins, payments, ticket printing</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/reports")}>
Reports
<div className="text-xs text-white/90">View, export, and email reports</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/forms") }>
Attendee forms
<div className="text-xs text-white/90">View submitted attendee forms</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/email-attendees") }>
Email attendees
<div className="text-xs text-white/90">Send message to attendees of an event</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/whatsapp-attendees") }>
WhatsApp attendees
<div className="text-xs text-white/90">Send WhatsApp message to event attendees</div>
</button>
</div>
</div>
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">Recent scans</h2>
{loadingStats && <span className="text-xs text-gray-500">Refreshing</span>}
</div>
<ul className="text-sm space-y-2 max-h-96 overflow-auto pr-2">
{recentScans.map((u: any) => (
<li key={u.id} className="border rounded p-2">
<div className="flex justify-between">
<div className="font-medium">{u.ticket?.event?.title || u.ticket?.eventId || 'Event'}</div>
<div className="text-xs text-gray-500">{new Date(u.scannedAt).toLocaleString()}</div>
</div>
<div className="text-xs text-gray-600">{u.ticket?.registrationOption?.eventOption?.name || 'Ticket'} #{String(u.ticket?.id || '').slice(0,8)}</div>
<div className="text-xs text-gray-500">Scanned by: {u.scannedBy?.name || u.scannedById}</div>
</li>
))}
{recentScans.length === 0 && <li className="text-gray-500">No scans yet.</li>}
</ul>
</div>
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">Scanner activity</h2>
{loadingStats && <span className="text-xs text-gray-500">Refreshing</span>}
</div>
{scanStats ? (
<div className="grid grid-cols-3 gap-2 mb-3">
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Today</div>
<div className="text-lg font-semibold">{scanStats.totalToday}</div>
</div>
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">My scans</div>
<div className="text-lg font-semibold">{scanStats.myToday}</div>
</div>
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Last hour</div>
<div className="text-lg font-semibold">{scanStats.lastHour}</div>
</div>
</div>
) : (
<div className="text-sm text-gray-500">No scanner data yet.</div>
)}
{scanStats?.byStaff?.length > 0 && (
<div className="mb-1">
<div className="text-sm font-medium mb-1">Today by staff</div>
<ul className="text-sm text-gray-700 space-y-1">
{scanStats.byStaff.map((s: any) => (
<li key={s.scannedById} className="flex justify-between">
<span>{s.name || 'Staff'}</span>
<span className="font-medium">{s.count}</span>
</li>
))}
</ul>
</div>
)}
</div>
</div>
<div>
<div className="border rounded-xl p-4 bg-white shadow-sm">
<h2 className="text-lg font-semibold mb-3">Supervisor stats</h2>
{loadingStats && <div className="text-sm text-gray-500 mb-2">Loading</div>}
<div className="space-y-2">
<div className="border rounded p-3 bg-white flex items-center justify-between">
<div>
<div className="text-xs text-gray-500">Active events</div>
<div className="text-lg font-semibold">{activeEventsCount}</div>
</div>
<button className="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200" onClick={() => router.push("/dashboard/staff/event-tickets")}>View</button>
</div>
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Revenue today</div>
<div className="text-lg font-semibold">R {(paymentStats?.totalToday || 0).toFixed(2)}</div>
</div>
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Donations today</div>
<div className="text-lg font-semibold">{paymentStats?.donationsToday || 0}</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,19 @@
"use client";
import React from "react";
import { useRouter } from "next/navigation";
import ReportsV2 from "@/components/reports/ReportsV2";
export default function SupervisorReportsPage() {
const router = useRouter();
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Reports</h1>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
</div>
<p className="text-sm text-gray-600 mb-4">View, export, or email operational reports for events.</p>
<ReportsV2 />
</div>
);
}
@@ -0,0 +1,12 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
export default function SectionsRedirectPage() {
const router = useRouter();
useEffect(() => {
router.replace("/dashboard/supervisor/events");
}, [router]);
return null;
}
@@ -0,0 +1,941 @@
"use client";
import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api";
// Attendee with preference info
type Attendee = { id: string; name: string; phone: string; pref: string };
type UserEntry = { id: string; name: string; phone: string; pref: string };
function toLocalInputValue(d: Date) {
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export default function WhatsAppAttendeesPage() {
return (
<Suspense fallback={<div className="p-6">Loading...</div>}>
<WhatsAppAttendeesPageInner />
</Suspense>
);
}
// ─── Attendees dropdown with preference indicators ────────────────────────────
function AttendeesCheckboxDropdown({
attendees,
loading,
selectedIds,
onChange,
channel = "whatsapp",
}: {
attendees: (Attendee | UserEntry)[];
loading: boolean;
selectedIds: string[];
onChange: (ids: string[]) => void;
channel?: "whatsapp" | "email";
}) {
const [open, setOpen] = useState(false);
const allIds = useMemo(() => attendees.map((a) => a.id), [attendees]);
const allSelected = selectedIds.length > 0 && selectedIds.length === allIds.length;
const toggleAll = (checked: boolean) => onChange(checked ? allIds : []);
const toggleId = (id: string) => {
if (selectedIds.includes(id)) onChange(selectedIds.filter((x) => x !== id));
else onChange([...selectedIds, id]);
};
const prefMatch = (pref: string) =>
channel === "whatsapp" ? pref === "whatsapp" || pref === "both" : pref === "email" || pref === "both";
const prefLabel = (pref: string) => {
if (pref === "both") return "both";
if (pref === "whatsapp") return "WA";
if (pref === "email") return "email";
return pref || "email";
};
const prefColor = (pref: string, match: boolean) =>
match ? "text-green-700 bg-green-50" : "text-amber-700 bg-amber-50";
const summary = loading
? "Loading…"
: attendees.length === 0
? "No attendees"
: allSelected
? `All (${attendees.length})`
: selectedIds.length === 0
? "None selected"
: `${selectedIds.length} selected`;
const mismatched = selectedIds.filter((id) => {
const a = attendees.find((x) => x.id === id);
return a && !prefMatch(a.pref);
});
return (
<div className="relative block w-full max-w-xs">
<button
type="button"
className="w-full border rounded px-3 py-2 text-sm bg-white hover:bg-gray-50 text-left"
onClick={() => setOpen((o) => !o)}
>
{summary}
{mismatched.length > 0 && (
<span className="ml-2 text-xs text-amber-600">({mismatched.length} pref mismatch)</span>
)}
</button>
{open && (
<div className="absolute z-10 mt-1 w-64 max-h-72 overflow-auto bg-white border rounded shadow">
<div className="px-3 py-2 border-b sticky top-0 bg-white space-y-1">
<label className="text-sm flex items-center gap-2">
<input type="checkbox" checked={allSelected} onChange={(e) => toggleAll(e.target.checked)} />
<span className="font-medium">Select all</span>
</label>
<div className="flex gap-1 flex-wrap">
<button
type="button"
className="text-xs px-2 py-0.5 rounded border border-green-300 text-green-700 hover:bg-green-50"
onClick={() => onChange(attendees.filter((a) => prefMatch(a.pref)).map((a) => a.id))}
>
Select {channel === "whatsapp" ? "WhatsApp/both" : "Email/both"}
</button>
<button
type="button"
className="text-xs px-2 py-0.5 rounded border border-gray-300 text-gray-600 hover:bg-gray-50"
onClick={() => onChange([])}
>
Clear
</button>
</div>
</div>
{loading ? (
<div className="px-3 py-2 text-sm text-gray-500">Loading</div>
) : attendees.length === 0 ? (
<div className="px-3 py-2 text-sm text-gray-500">No attendees with phone numbers</div>
) : (
<ul className="py-1">
{attendees.map((a) => {
const checked = selectedIds.includes(a.id);
const match = prefMatch(a.pref);
return (
<li key={a.id} className={`px-3 py-1 hover:bg-gray-50 ${!match ? "opacity-75" : ""}`}>
<label className="flex items-center gap-2 text-sm min-w-0">
<input type="checkbox" checked={checked} onChange={() => toggleId(a.id)} />
<span className="truncate flex-1">
{a.name ? `${a.name} (${a.phone})` : a.phone}
</span>
<span className={`text-[10px] px-1 rounded shrink-0 ${prefColor(a.pref, match)}`}>
{prefLabel(a.pref)}
</span>
</label>
</li>
);
})}
</ul>
)}
<div className="px-3 py-2 border-t bg-gray-50 text-right">
<button
type="button"
className="px-2 py-1 text-xs rounded border bg-white hover:bg-gray-100"
onClick={() => setOpen(false)}
>
Done
</button>
</div>
</div>
)}
</div>
);
}
// ─── Preference warning banner ────────────────────────────────────────────────
function PrefWarning({ attendees, selectedIds, channel }: { attendees: (Attendee|UserEntry)[]; selectedIds: string[]; channel: "whatsapp" | "email" }) {
const prefMatch = (pref: string) =>
channel === "whatsapp" ? pref === "whatsapp" || pref === "both" : pref === "email" || pref === "both";
const mismatched = useMemo(
() => selectedIds.filter((id) => {
const a = attendees.find((x) => x.id === id);
return a && !prefMatch(a.pref);
}),
[attendees, selectedIds, channel]
);
if (mismatched.length === 0) return null;
return (
<div className="p-3 border rounded bg-amber-50 text-amber-800 text-xs">
<strong>{mismatched.length} selected attendee(s)</strong> have a notification preference that doesn&apos;t include{" "}
{channel === "whatsapp" ? "WhatsApp" : "email"}. They will still receive the message, but it may not be their preferred channel.
{" "}<button
type="button"
className="underline ml-1"
onClick={() => {/* handled by dropdown */}}
>
Use the dropdown to filter by preference.
</button>
</div>
);
}
// ─── WhatsApp Automations Panel ───────────────────────────────────────────────
function WAAutomationsPanel({ events, token, onInfo, onError }: { events: any[]; token?: string | null; onInfo: (s: string) => void; onError: (s: string) => void }) {
const [autoEventId, setAutoEventId] = React.useState<string>("");
const currentEvent = React.useMemo(() => (events || []).find((e: any) => e.id === autoEventId), [events, autoEventId]);
const preWeekDefault = `Hi {{name}}\n\nJust a friendly reminder that {{event.title}} is one week away!\n\nEvent details: {{event.link}}`;
const finalDefault = `Hi {{name}}\n\nFinal reminder for {{event.title}}.\nPlease have your QR code/ticket ready at entry.\n\nEvent details: {{event.link}}`;
const thanksDefault = `Hi {{name}}\n\nThank you for joining us at {{event.title}}!\nWe hope you had a great time. See you next time!`;
const promoDefault = `Hi {{name}}\n\nWe'd love to see you at our next event: {{promo.title}}.\nFind out more and register here: {{promo.link}}`;
const [enablePre, setEnablePre] = React.useState(true);
const [enableFinal, setEnableFinal] = React.useState(true);
const [enableThanks, setEnableThanks] = React.useState(true);
const [enablePromo, setEnablePromo] = React.useState(false);
const [preBody, setPreBody] = React.useState(preWeekDefault);
const [finalBody, setFinalBody] = React.useState(finalDefault);
const [thanksBody, setThanksBody] = React.useState(thanksDefault);
const [promoBody, setPromoBody] = React.useState(promoDefault);
const [finalHours, setFinalHours] = React.useState<"24" | "48">("24");
const [preWhen, setPreWhen] = React.useState<string>("");
const [finalWhen, setFinalWhen] = React.useState<string>("");
const [thanksWhen, setThanksWhen] = React.useState<string>("");
const [promoWhen, setPromoWhen] = React.useState<string>("");
const [promoEventId, setPromoEventId] = React.useState<string>("");
React.useEffect(() => {
if (!currentEvent) { setPreWhen(""); setFinalWhen(""); setThanksWhen(""); setPromoWhen(""); return; }
try {
const start = new Date(currentEvent.startDate);
const end = new Date(currentEvent.endDate || currentEvent.startDate);
const pre = new Date(start); pre.setDate(pre.getDate() - 7); pre.setHours(9, 0, 0, 0);
setPreWhen(toLocalInputValue(pre));
const fin = new Date(start); fin.setHours(fin.getHours() - (finalHours === "48" ? 48 : 24));
setFinalWhen(toLocalInputValue(fin));
const ty = new Date(end); ty.setDate(ty.getDate() + 1); ty.setHours(9, 0, 0, 0);
setThanksWhen(toLocalInputValue(ty));
const pr = new Date(end); pr.setDate(pr.getDate() + 3); pr.setHours(9, 0, 0, 0);
setPromoWhen(toLocalInputValue(pr));
} catch {}
}, [currentEvent, finalHours]);
const onSchedule = async () => {
try {
onError(null as any); onInfo(null as any);
if (!token) { onError("Not authenticated"); return; }
if (!autoEventId) { onError("Please select an event"); return; }
const jobs: any[] = [];
if (enablePre && preBody.trim() && preWhen) jobs.push({ message: preBody, scheduledAt: new Date(preWhen).toISOString() });
if (enableFinal && finalBody.trim() && finalWhen) jobs.push({ message: finalBody, scheduledAt: new Date(finalWhen).toISOString() });
if (enableThanks && thanksBody.trim() && thanksWhen) jobs.push({ message: thanksBody, scheduledAt: new Date(thanksWhen).toISOString() });
if (enablePromo && promoBody.trim() && promoWhen) jobs.push({ message: promoBody, scheduledAt: new Date(promoWhen).toISOString(), promoEventId: promoEventId || undefined });
if (jobs.length === 0) { onError("Please enable at least one automation and fill in details"); return; }
let scheduled = 0;
for (const job of jobs) {
await apiFetch(`/api/events/${encodeURIComponent(autoEventId)}/whatsapp-attendees/schedule`, {
method: "POST", authToken: token,
body: { message: job.message, scheduledAt: job.scheduledAt, filter: { status: "paid" } },
});
scheduled++;
}
onInfo(`Scheduled ${scheduled} WhatsApp automation(s).`);
} catch (e: any) {
onError(e?.message || "Failed to schedule automations");
}
};
return (
<div className="grid gap-4">
<div>
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={autoEventId} onChange={(e) => setAutoEventId(e.target.value)}>
<option value="">Select an event</option>
{(events || []).map((ev: any) => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
</div>
<div className="grid gap-4">
<fieldset className="border rounded p-3">
<legend className="text-sm font-medium">Pre-Event Reminder (1 week before)</legend>
<label className="inline-flex items-center gap-2 text-sm mb-2">
<input type="checkbox" checked={enablePre} onChange={(e) => setEnablePre(e.target.checked)} /> Enable
</label>
<div>
<label className="block text-xs text-gray-600 mb-1">Send at</label>
<input className="w-full border rounded px-3 py-2 text-sm" type="datetime-local" value={preWhen} onChange={(e) => setPreWhen(e.target.value)} />
</div>
<label className="block text-xs text-gray-600 mb-1 mt-2">Message</label>
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={4} value={preBody} onChange={(e) => setPreBody(e.target.value)} />
</fieldset>
<fieldset className="border rounded p-3">
<legend className="text-sm font-medium">Final Reminder (2448 hrs before)</legend>
<div className="flex items-center gap-4 mb-2">
<label className="inline-flex items-center gap-2 text-sm">
<input type="checkbox" checked={enableFinal} onChange={(e) => setEnableFinal(e.target.checked)} /> Enable
</label>
<label className="text-xs text-gray-600">Hours before:</label>
<select className="border rounded px-2 py-1 text-sm" value={finalHours} onChange={(e) => setFinalHours(e.target.value as any)}>
<option value="24">24</option>
<option value="48">48</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Send at</label>
<input className="w-full border rounded px-3 py-2 text-sm" type="datetime-local" value={finalWhen} onChange={(e) => setFinalWhen(e.target.value)} />
</div>
<label className="block text-xs text-gray-600 mb-1 mt-2">Message</label>
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={4} value={finalBody} onChange={(e) => setFinalBody(e.target.value)} />
</fieldset>
<fieldset className="border rounded p-3">
<legend className="text-sm font-medium">Thank You / Wrap Up</legend>
<label className="inline-flex items-center gap-2 text-sm mb-2">
<input type="checkbox" checked={enableThanks} onChange={(e) => setEnableThanks(e.target.checked)} /> Enable
</label>
<div>
<label className="block text-xs text-gray-600 mb-1">Send at</label>
<input className="w-full border rounded px-3 py-2 text-sm" type="datetime-local" value={thanksWhen} onChange={(e) => setThanksWhen(e.target.value)} />
</div>
<label className="block text-xs text-gray-600 mb-1 mt-2">Message</label>
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={4} value={thanksBody} onChange={(e) => setThanksBody(e.target.value)} />
</fieldset>
<fieldset className="border rounded p-3">
<legend className="text-sm font-medium">Next Event Promo</legend>
<div className="flex items-center gap-4 mb-2">
<label className="inline-flex items-center gap-2 text-sm">
<input type="checkbox" checked={enablePromo} onChange={(e) => setEnablePromo(e.target.checked)} /> Enable
</label>
</div>
<div className="grid sm:grid-cols-2 gap-3 mb-2">
<div>
<label className="block text-xs text-gray-600 mb-1">Promo Event</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={promoEventId} onChange={(e) => setPromoEventId(e.target.value)}>
<option value="">Select event to promote</option>
{(events || []).map((ev: any) => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Send at</label>
<input className="w-full border rounded px-3 py-2 text-sm" type="datetime-local" value={promoWhen} onChange={(e) => setPromoWhen(e.target.value)} />
</div>
</div>
<label className="block text-xs text-gray-600 mb-1">Message</label>
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={4} value={promoBody} onChange={(e) => setPromoBody(e.target.value)} />
</fieldset>
</div>
<div className="flex items-center gap-2">
<button type="button" className="px-3 py-1.5 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700" onClick={onSchedule}>
Schedule selected
</button>
<div className="text-[11px] text-gray-500">
Placeholders: {"{{name}}"}, {"{{event.title}}"}, {"{{event.start}}"}, {"{{event.link}}"}, {"{{promo.title}}"}, {"{{promo.link}}"}
</div>
</div>
</div>
);
}
// ─── Main Page ────────────────────────────────────────────────────────────────
function WhatsAppAttendeesPageInner() {
const { user, loading, token } = useAuth();
const router = useRouter();
const search = useSearchParams();
const preselectEventId = search?.get("eventId") || "";
const canView = useMemo(() => {
const role = user?.role;
return role === "admin" || role === "supervisor";
}, [user]);
useEffect(() => {
if (loading) return;
if (!user) router.replace("/login");
}, [user, loading, router]);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [tab, setTab] = useState<"attendees" | "automations" | "broadcasts" | "scheduled">("attendees");
const [loadingEvents, setLoadingEvents] = useState(false);
const [allEvents, setAllEvents] = useState<any[]>([]);
const [evIncludePast, setEvIncludePast] = useState(false);
const events = useMemo(() => {
const now = Date.now();
if (evIncludePast) return allEvents;
return allEvents.filter((ev) => !isNaN(new Date(ev.endDate).getTime()) && new Date(ev.endDate).getTime() > now);
}, [allEvents, evIncludePast]);
useEffect(() => {
const loadEvents = async () => {
try {
setLoadingEvents(true);
const evs = await apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token || undefined });
setAllEvents((evs || []).sort((a: any, b: any) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()));
} catch (e: any) {
setError(e?.message || "Failed to load events");
} finally {
setLoadingEvents(false);
}
};
loadEvents();
}, [user, token]);
// ── Attendees tab ──────────────────────────────────────────────────────────
const [eventId, setEventId] = useState<string>(preselectEventId);
useEffect(() => { if (preselectEventId) setEventId(preselectEventId); }, [preselectEventId]);
const [templateKey, setTemplateKey] = useState<"custom" | "payment_reminder" | "event_reminder" | "tickets">("custom");
const [message, setMessage] = useState("");
const [messageDirty, setMessageDirty] = useState(false);
const [status, setStatus] = useState<"any" | "paid" | "unpaid" | "partial_paid" | "cancelled">("any");
const [attendees, setAttendees] = useState<Attendee[]>([]);
const [selectedAttendeeIds, setSelectedAttendeeIds] = useState<string[]>([]);
const [loadingAttendees, setLoadingAttendees] = useState(false);
const [previewCount, setPreviewCount] = useState<number | null>(null);
const [previewSample, setPreviewSample] = useState<{ phone: string; name?: string }[] | null>(null);
const [sending, setSending] = useState(false);
const [scheduledAtLocal, setScheduledAtLocal] = useState<string>("");
const [showInfo, setShowInfo] = useState(false);
const currentEvent = useMemo(() => (events || []).find((e) => e.id === eventId), [events, eventId]);
useEffect(() => {
const title = currentEvent?.title || "the event";
if (templateKey === "payment_reminder") {
if (!messageDirty) setMessage(`Hi {{name}}\n\nFriendly reminder: you have an outstanding balance for ${title}.\n\nPlease settle your balance to secure your tickets. Thank you!`);
} else if (templateKey === "event_reminder") {
if (!messageDirty) setMessage(`Hi {{name}}\n\nA quick reminder about ${title}.\nStart: {{event.start}}\n\nWe look forward to seeing you!`);
} else if (templateKey === "tickets") {
setMessageDirty(false); setMessage("");
}
}, [templateKey, currentEvent]);
useEffect(() => {
const run = async () => {
try {
setLoadingAttendees(true); setAttendees([]); setSelectedAttendeeIds([]);
if (!eventId || !token) return;
const regs = await apiFetch<any[]>(`/api/registrations/event/${encodeURIComponent(eventId)}`, { authToken: token });
const uniq = new Map<string, Attendee>();
(regs || []).forEach((r: any) => {
const u = r?.user;
if (u?.id && u?.phoneNumber && !u.email?.endsWith("@guest.local") && u.isActive !== false) {
uniq.set(u.id, { id: u.id, name: u.name || "", phone: u.phoneNumber, pref: u.notificationPreference || "email" });
}
});
const list = Array.from(uniq.values()).sort((a, b) => (a.name || "").localeCompare(b.name || "", undefined, { sensitivity: "base" }));
setAttendees(list);
// Default: select those with whatsapp/both preference
const matching = list.filter((a) => a.pref === "whatsapp" || a.pref === "both").map((a) => a.id);
setSelectedAttendeeIds(matching.length > 0 ? matching : list.map((a) => a.id));
} catch { } finally { setLoadingAttendees(false); }
};
run();
}, [eventId, token]);
const buildPayload = (dryRun?: boolean) => {
const payload: any = {
filter: { status: status !== "any" ? status : undefined, attendeeIds: selectedAttendeeIds.length ? selectedAttendeeIds : undefined },
template: templateKey,
dryRun: dryRun || undefined,
};
if (templateKey !== "tickets") payload.message = message;
return payload;
};
const onPreview = async () => {
try {
setError(null); setInfo(null); setPreviewCount(null); setPreviewSample(null);
if (!token) { setError("Not authenticated"); return; }
if (!eventId) { setError("Please select an event"); return; }
if (templateKey !== "tickets" && !message.trim()) { setError("Message is required"); return; }
const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees`, { method: "POST", authToken: token, body: buildPayload(true) });
setPreviewCount(res?.matched ?? 0);
setPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null);
setInfo(`Matched ${res?.matched ?? 0} recipient(s) with a phone number.`);
} catch (e: any) { setError(e?.message || "Failed to preview"); }
};
const resetAttendeesForm = () => {
setTemplateKey("custom"); setMessage(""); setMessageDirty(false);
setStatus("any"); setScheduledAtLocal(""); setPreviewCount(null); setPreviewSample(null);
setSelectedAttendeeIds(attendees.map((a) => a.id));
};
const onSend = async () => {
try {
setError(null); setInfo(null);
if (!token) { setError("Not authenticated"); return; }
if (!eventId) { setError("Please select an event"); return; }
if (templateKey !== "tickets" && !message.trim()) { setError("Message is required"); return; }
setSending(true);
const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees`, { method: "POST", authToken: token, body: buildPayload() });
setInfo(`Sent ${res?.sent ?? 0} out of ${res?.matched ?? 0} recipient(s).`);
resetAttendeesForm();
} catch (e: any) { setError(e?.message || "Failed to send"); } finally { setSending(false); }
};
const onScheduleSend = async () => {
try {
setError(null); setInfo(null);
if (!token) { setError("Not authenticated"); return; }
if (!eventId) { setError("Please select an event"); return; }
if (templateKey !== "tickets" && !message.trim()) { setError("Message is required"); return; }
if (!scheduledAtLocal) { setError("Scheduled time is required"); return; }
const whenIso = new Date(scheduledAtLocal).toISOString();
const payload: any = {
message: message || undefined,
filter: { status: status !== "any" ? status : undefined, attendeeIds: selectedAttendeeIds.length ? selectedAttendeeIds : undefined },
template: templateKey, scheduledAt: whenIso,
};
const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees/schedule`, { method: "POST", authToken: token, body: payload });
setInfo(res?.job?.id ? "WhatsApp scheduled. It will be sent around the specified time." : "Scheduled.");
resetAttendeesForm();
} catch (e: any) { setError(e?.message || "Failed to schedule"); }
};
// ── Broadcasts tab ─────────────────────────────────────────────────────────
const [users, setUsers] = useState<UserEntry[]>([]);
const [loadingUsers, setLoadingUsers] = useState(false);
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
const [broadcastEventId, setBroadcastEventId] = useState<string>("");
const [broadcastMessage, setBroadcastMessage] = useState("");
const [broadcastPhones, setBroadcastPhones] = useState("");
const [broadcastPreviewCount, setBroadcastPreviewCount] = useState<number | null>(null);
const [broadcastPreviewSample, setBroadcastPreviewSample] = useState<{ phone: string; name?: string }[] | null>(null);
const [broadcastScheduledAtLocal, setBroadcastScheduledAtLocal] = useState<string>("");
useEffect(() => {
const run = async () => {
try {
if (!token) return;
setLoadingUsers(true);
const allUsers = await fetchAllUsers(token);
const mapped = allUsers
.filter((u: any) => u.isActive !== false && !u.email?.endsWith("@guest.local") && u.phoneNumber)
.map((u: any) => ({ id: u.id, name: u.name || "", phone: u.phoneNumber, pref: u.notificationPreference || "email" }))
.sort((a: any, b: any) => (a.name || "").localeCompare(b.name || "", undefined, { sensitivity: "base" }));
setUsers(mapped);
} catch { } finally { setLoadingUsers(false); }
};
run();
}, [token]);
const resetBroadcastForm = () => {
setSelectedUserIds([]); setBroadcastEventId(""); setBroadcastMessage("");
setBroadcastPhones(""); setBroadcastPreviewCount(null); setBroadcastPreviewSample(null);
setBroadcastScheduledAtLocal("");
};
// ── Scheduled tab ──────────────────────────────────────────────────────────
type ScheduledJob = { id: string; kind: string; eventId?: string | null; broadcast?: boolean; channel?: string; scheduledAt: string; createdAt: string; status: string; attempts: number; sentAt?: string | null; lastError?: string | null; subject?: string; payload?: any };
const [scheduled, setScheduled] = useState<ScheduledJob[]>([]);
const [loadingScheduled, setLoadingScheduled] = useState(false);
const [editing, setEditing] = useState<ScheduledJob | null>(null);
const [editMessage, setEditMessage] = useState<string>("");
const [editWhen, setEditWhen] = useState<string>("");
const [savingEdit, setSavingEdit] = useState(false);
const loadScheduled = async () => {
try {
if (!token) return;
setLoadingScheduled(true);
const res = await apiFetch<{ jobs: ScheduledJob[] }>(`/api/scheduled-emails`, { authToken: token });
// Filter to only WhatsApp jobs
const all = Array.isArray(res?.jobs) ? res.jobs : [];
setScheduled(all.filter((j) => j.channel === "whatsapp" || (j.broadcast && j.channel === "whatsapp")));
} catch { } finally { setLoadingScheduled(false); }
};
useEffect(() => { if (tab === "scheduled") loadScheduled(); }, [tab, token]);
const saveEdit = async () => {
if (!editing) return;
try {
setSavingEdit(true);
if (!token) { setError("Not authenticated"); return; }
const body: any = {};
if (editWhen) body.scheduledAt = new Date(editWhen).toISOString();
if (editMessage.trim()) body.text = editMessage;
await apiFetch(`/api/scheduled-emails/${encodeURIComponent(editing.id)}`, { method: "PATCH", authToken: token, body });
setInfo("Scheduled message updated.");
setEditing(null);
loadScheduled();
} catch (e: any) { setError(e?.message || "Failed to update"); } finally { setSavingEdit(false); }
};
const removeJob = async (job: ScheduledJob) => {
try {
if (!token) { setError("Not authenticated"); return; }
await apiFetch(`/api/scheduled-emails/${encodeURIComponent(job.id)}`, { method: "DELETE", authToken: token });
setInfo("Scheduled message removed.");
loadScheduled();
} catch (e: any) { setError(e?.message || "Failed to remove"); }
};
// ── Render ─────────────────────────────────────────────────────────────────
const mismatchedAttendees = useMemo(
() => selectedAttendeeIds.filter((id) => {
const a = attendees.find((x) => x.id === id);
return a && a.pref !== "whatsapp" && a.pref !== "both";
}),
[attendees, selectedAttendeeIds]
);
return (
<div className="max-w-3xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">WhatsApp Attendees</h1>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push("/dashboard")}>
Back
</button>
</div>
{!canView && (
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
You need supervisor or admin access to use this page.
</div>
)}
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
{info && <div className="p-3 mb-3 border rounded bg-emerald-50 text-emerald-800 text-sm">{info}</div>}
{/* Tabs */}
<div className="mb-4 flex items-center gap-2 flex-wrap">
{(["attendees", "automations", "broadcasts", "scheduled"] as const).map((t) => (
<label key={t} className={`px-3 py-1.5 text-sm rounded border cursor-pointer ${tab === t ? "bg-green-600 text-white border-green-600" : "bg-white text-gray-800 border-gray-200"}`}>
<input type="radio" name="waTab" value={t} className="hidden" checked={tab === t} onChange={() => setTab(t)} />
{t.charAt(0).toUpperCase() + t.slice(1)}
</label>
))}
</div>
{/* ── Attendees Tab ── */}
{tab === "attendees" && (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="grid gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Event</label>
<div className="flex gap-2 items-center">
<select className="border rounded px-3 py-2 text-sm flex-1 min-w-0" value={eventId} onChange={(e) => setEventId(e.target.value)}>
<option value="">Select an event</option>
{events.map((ev) => (
<option key={ev.id} value={ev.id}>{ev.title}{ev.endDate && new Date(ev.endDate) < new Date() ? " (past)" : ""}</option>
))}
</select>
{loadingEvents && <span className="text-xs text-gray-500">Loading</span>}
</div>
<label className="flex items-center gap-1.5 text-xs text-gray-500 mt-1 cursor-pointer">
<input type="checkbox" checked={evIncludePast} onChange={(e) => setEvIncludePast(e.target.checked)} />
Include past events
</label>
</div>
<div className="grid sm:grid-cols-3 gap-3">
<div>
<div className="flex items-center justify-between">
<label className="block text-xs text-gray-600 mb-1">Template</label>
<button type="button" className="text-[11px] text-gray-600 hover:text-gray-900 inline-flex items-center gap-1" onClick={() => setShowInfo(true)}>
<span className="inline-flex items-center justify-center w-4 h-4 rounded-full border border-gray-300 text-[10px]">i</span>
Info
</button>
</div>
<select className="w-full border rounded px-3 py-2 text-sm" value={templateKey} onChange={(e) => setTemplateKey(e.target.value as any)}>
<option value="custom">Custom message</option>
<option value="payment_reminder">Payment reminder</option>
<option value="event_reminder">Event reminder</option>
<option value="tickets">Send ticket PDFs</option>
</select>
</div>
<div className="sm:col-span-2">
<div className="text-[11px] text-gray-500 mt-6">
Placeholders: {"{{name}}"}, {"{{event.title}}"}, {"{{event.start}}"}, {"{{balance}}"}
</div>
</div>
</div>
{showInfo && (
<div className="fixed inset-0 z-20">
<div className="absolute inset-0 bg-black/30" onClick={() => setShowInfo(false)} />
<div className="absolute inset-0 flex items-center justify-center p-4">
<div className="w-full max-w-lg bg-white rounded-lg shadow-lg border p-4">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold">Dynamic parameters</h3>
<button type="button" className="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200" onClick={() => setShowInfo(false)}>Close</button>
</div>
<div className="text-[12px] text-gray-700 break-words">
<p className="mb-2">Personalize your message per recipient using these placeholders.</p>
<ul className="list-disc pl-5 space-y-1 mb-2">
<li><code>{"{{name}}"}</code> attendee&apos;s name.</li>
<li><code>{"{{event.title}}"}</code> the event title.</li>
<li><code>{"{{event.start}}"}</code> the event start date/time.</li>
<li><code>{"{{balance}}"}</code> outstanding balance for the event.</li>
</ul>
<p className="text-[11px] text-gray-500 mt-2">
Preference indicators: <span className="text-green-700 bg-green-50 px-1 rounded">WA</span> = WhatsApp only,{" "}
<span className="text-green-700 bg-green-50 px-1 rounded">both</span> = WhatsApp + Email,{" "}
<span className="text-amber-700 bg-amber-50 px-1 rounded">email</span> = Email only (will still receive message).
</p>
</div>
</div>
</div>
</div>
)}
<div className="grid sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Payment status filter</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={status} onChange={(e) => setStatus(e.target.value as any)}>
<option value="any">Any</option>
<option value="paid">Paid</option>
<option value="unpaid">Unpaid (pending or partial)</option>
<option value="partial_paid">Partial paid</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
</div>
{templateKey !== "tickets" ? (
<div>
<label className="block text-xs text-gray-600 mb-1">Message</label>
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={8} value={message}
onChange={(e) => { setMessage(e.target.value); setMessageDirty(true); }}
placeholder="Write your WhatsApp message to attendees..." />
</div>
) : (
<div className="p-3 border rounded bg-gray-50 text-sm text-gray-700">
This will send each selected attendee their ticket PDF(s) for this event via WhatsApp.
</div>
)}
<div className="grid sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Attendees</label>
<AttendeesCheckboxDropdown attendees={attendees} loading={loadingAttendees} selectedIds={selectedAttendeeIds} onChange={setSelectedAttendeeIds} channel="whatsapp" />
<div className="text-[11px] text-gray-500 mt-1">Attendees with WhatsApp/both preference are pre-selected.</div>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Send at (optional)</label>
<input className="w-full border rounded px-3 py-2 text-sm" type="datetime-local" value={scheduledAtLocal} onChange={(e) => setScheduledAtLocal(e.target.value)} />
<div className="text-[10px] text-gray-500 mt-1">Leave empty to send immediately.</div>
</div>
</div>
{mismatchedAttendees.length > 0 && (
<div className="p-3 border rounded bg-amber-50 text-amber-800 text-xs">
<strong>{mismatchedAttendees.length} selected attendee(s)</strong> prefer email only they will still receive this message but it&apos;s not their preferred channel.
</div>
)}
<div className="flex items-center flex-wrap gap-2">
<button type="button" disabled={sending} className="px-3 py-1.5 text-sm rounded border bg-white hover:bg-gray-50" onClick={onPreview}>
Preview recipients
</button>
<button type="button" disabled={sending} className="px-3 py-1.5 text-sm rounded bg-green-600 text-white hover:bg-green-700 disabled:opacity-50" onClick={onSend}>
{sending ? "Sending…" : "Send now"}
</button>
<button type="button" disabled={sending || !scheduledAtLocal} className="px-3 py-1.5 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50" onClick={onScheduleSend}>
Schedule send
</button>
{previewCount != null && <span className="text-xs text-gray-600">Preview: {previewCount} recipient(s)</span>}
</div>
{previewSample && previewSample.length > 0 && (
<div className="mt-2">
<div className="text-xs text-gray-600 mb-1">First {previewSample.length} recipient(s):</div>
<ul className="text-xs text-gray-800 list-disc pl-5 space-y-0.5">
{previewSample.map((r, idx) => (
<li key={idx}>{r.name ? `${r.name} (${r.phone})` : r.phone}</li>
))}
</ul>
</div>
)}
</div>
</div>
)}
{/* ── Automations Tab ── */}
{tab === "automations" && (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<WAAutomationsPanel events={events} token={token} onInfo={setInfo} onError={setError} />
</div>
)}
{/* ── Broadcasts Tab ── */}
{tab === "broadcasts" && (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="grid gap-3">
<div className="grid sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Users (optional)</label>
<AttendeesCheckboxDropdown attendees={users} loading={loadingUsers} selectedIds={selectedUserIds} onChange={setSelectedUserIds} channel="whatsapp" />
<div className="text-[11px] text-gray-500 mb-1">Only users with phone numbers shown. Green = WhatsApp/both preference.</div>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Event (optional for placeholders)</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={broadcastEventId} onChange={(e) => setBroadcastEventId(e.target.value)}>
<option value="">No event</option>
{events.map((ev) => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
</div>
</div>
{selectedUserIds.length > 0 && (
<PrefWarning attendees={users} selectedIds={selectedUserIds} channel="whatsapp" />
)}
<div>
<label className="block text-xs text-gray-600 mb-1">Message</label>
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={8} value={broadcastMessage}
onChange={(e) => setBroadcastMessage(e.target.value)}
placeholder="Write your message... Use {{name}}, {{event.title}}, {{event.start}}, {{event.link}}" />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Additional phone numbers (one per line)</label>
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={4} value={broadcastPhones}
onChange={(e) => setBroadcastPhones(e.target.value)}
placeholder={`0821234567\nJane Doe <0721234567>\n27831234567`} />
<div className="text-[11px] text-gray-500 mt-1">Formats: 0821234567 or Name &lt;0821234567&gt;</div>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Send at (optional)</label>
<input className="w-full border rounded px-3 py-2 text-sm" type="datetime-local" value={broadcastScheduledAtLocal} onChange={(e) => setBroadcastScheduledAtLocal(e.target.value)} />
<div className="text-[10px] text-gray-500 mt-1">Leave empty to send immediately.</div>
</div>
<div className="flex items-center flex-wrap gap-2">
<button type="button" className="px-3 py-1.5 text-sm rounded border bg-white hover:bg-gray-50" onClick={async () => {
try {
setError(null); setInfo(null); setBroadcastPreviewCount(null); setBroadcastPreviewSample(null);
if (!token) { setError("Not authenticated"); return; }
const res = await apiFetch(`/api/whatsapp-broadcasts/preview`, { method: "POST", authToken: token, body: { userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined } });
setBroadcastPreviewCount(res?.matched ?? 0);
setBroadcastPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null);
setInfo(`Matched ${res?.matched ?? 0} recipient(s).`);
} catch (e: any) { setError(e?.message || "Failed to preview"); }
}}>Preview recipients</button>
<button type="button" className="px-3 py-1.5 text-sm rounded bg-green-600 text-white hover:bg-green-700 disabled:opacity-50" onClick={async () => {
try {
setError(null); setInfo(null);
if (!token) { setError("Not authenticated"); return; }
if (!broadcastMessage.trim()) { setError("Message is required"); return; }
const res = await apiFetch(`/api/whatsapp-broadcasts/send`, { method: "POST", authToken: token, body: { message: broadcastMessage, userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined } });
setInfo(`Sent ${res?.sent ?? 0} out of ${res?.matched ?? 0} recipient(s).`);
resetBroadcastForm();
} catch (e: any) { setError(e?.message || "Failed to send"); }
}}>Send now</button>
<button type="button" disabled={!broadcastScheduledAtLocal} className="px-3 py-1.5 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50" onClick={async () => {
try {
setError(null); setInfo(null);
if (!token) { setError("Not authenticated"); return; }
if (!broadcastMessage.trim()) { setError("Message is required"); return; }
const whenIso = new Date(broadcastScheduledAtLocal).toISOString();
const res = await apiFetch(`/api/whatsapp-broadcasts/schedule`, { method: "POST", authToken: token, body: { scheduledAt: whenIso, message: broadcastMessage, userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined } });
if (res?.job?.id) setInfo("WhatsApp broadcast scheduled."); else setInfo("Scheduled.");
resetBroadcastForm();
} catch (e: any) { setError(e?.message || "Failed to schedule broadcast"); }
}}>Schedule send</button>
{broadcastPreviewCount != null && <span className="text-xs text-gray-600">Preview: {broadcastPreviewCount} recipient(s)</span>}
</div>
{broadcastPreviewSample && broadcastPreviewSample.length > 0 && (
<div className="mt-2">
<div className="text-xs text-gray-600 mb-1">First {broadcastPreviewSample.length} recipient(s):</div>
<ul className="text-xs text-gray-800 list-disc pl-5 space-y-0.5">
{broadcastPreviewSample.map((r, idx) => (
<li key={idx}>{r.name ? `${r.name} (${r.phone})` : r.phone}</li>
))}
</ul>
</div>
)}
</div>
</div>
)}
{/* ── Scheduled Tab ── */}
{tab === "scheduled" && (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-medium">Scheduled WhatsApp Messages</h2>
<button type="button" className="text-sm px-2 py-1 rounded border bg-white hover:bg-gray-50" onClick={loadScheduled}>Refresh</button>
</div>
{loadingScheduled ? (
<div className="text-sm text-gray-600">Loading</div>
) : scheduled.length === 0 ? (
<div className="text-sm text-gray-600">No scheduled WhatsApp messages. Items sent more than a week ago are hidden.</div>
) : (
<ul className="divide-y border rounded">
{scheduled.map((job) => (
<li key={job.id} className="p-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 flex items-center gap-2">
<span className="inline-block px-2 py-0.5 text-xs rounded border bg-green-50 text-green-700">{job.broadcast ? "broadcast" : "attendees"}</span>
<span className="truncate text-gray-700">{job.payload?.message ? String(job.payload.message).slice(0, 60) + (String(job.payload.message).length > 60 ? "…" : "") : "(no message)"}</span>
</div>
<div className="text-xs text-gray-600 mt-1">
<span className="mr-2">Status: {job.status}</span>
<span className="mr-2">Scheduled: {(() => { try { return new Date(job.scheduledAt).toLocaleString(); } catch { return job.scheduledAt; } })()}</span>
{job.sentAt && <span>Sent: {(() => { try { return new Date(job.sentAt!).toLocaleString(); } catch { return job.sentAt; } })()}</span>}
{job.lastError && <span className="ml-2 text-red-600">Error: {job.lastError}</span>}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<button type="button" disabled={job.status !== "queued"} className="px-2 py-1 text-xs rounded border bg-white hover:bg-gray-50 disabled:opacity-50"
onClick={() => { setEditing(job); setEditMessage(job.payload?.message || ""); try { setEditWhen(toLocalInputValue(new Date(job.scheduledAt))); } catch { setEditWhen(""); } }}>
Edit
</button>
<button type="button" disabled={job.status !== "queued"} className="px-2 py-1 text-xs rounded border bg-white hover:bg-gray-50 disabled:opacity-50" onClick={() => removeJob(job)}>
Remove
</button>
</div>
</li>
))}
</ul>
)}
{editing && (
<div className="fixed inset-0 z-20">
<div className="absolute inset-0 bg-black/30" onClick={() => setEditing(null)} />
<div className="absolute inset-0 flex items-center justify-center p-4">
<div className="w-full max-w-lg bg-white rounded-lg shadow-lg border p-4">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold">Edit scheduled WhatsApp message</h3>
<button type="button" className="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200" onClick={() => setEditing(null)}>Close</button>
</div>
<div className="grid gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Message</label>
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={6} value={editMessage} onChange={(e) => setEditMessage(e.target.value)} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Send at</label>
<input className="w-full border rounded px-3 py-2 text-sm" type="datetime-local" value={editWhen} onChange={(e) => setEditWhen(e.target.value)} />
</div>
<div className="flex items-center gap-2">
<button type="button" disabled={savingEdit} className="px-3 py-1.5 text-sm rounded bg-green-600 text-white hover:bg-green-700 disabled:opacity-50" onClick={saveEdit}>
{savingEdit ? "Saving…" : "Save changes"}
</button>
<button type="button" className="px-3 py-1.5 text-sm rounded border bg-white hover:bg-gray-50" onClick={() => setEditing(null)}>Cancel</button>
</div>
</div>
</div>
</div>
</div>
)}
</div>
)}
</div>
);
}