Initial commit
Next.js + Express event management app for Hope Family Church.
This commit is contained in:
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 “Edit form structure” to add fields.</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-gray-600">
|
||||
Main tickets: <span className="font-medium">{mainTicketCount}</span>
|
||||
{mainTicketCount === 0 && <span className="ml-2 text-amber-600 text-xs">(No main tickets found — check registration options)</span>}
|
||||
</div>
|
||||
{editableResponses.map((resp, idx) => (
|
||||
<div key={idx} className="border rounded-lg p-3 bg-white shadow-sm">
|
||||
<div className="text-sm font-semibold mb-3 text-gray-700">Attendee #{idx+1}</div>
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
{fillForm.fields.filter(f=>f.type!=='statement' && f.type !== 'paragraph').map((f:any)=> (
|
||||
<div key={f.id}>
|
||||
<label className="block text-xs text-gray-600 mb-1">{f.label}{f.isRequired ? ' *' : ''}</label>
|
||||
{f.type === 'text' && (
|
||||
<input className="border rounded px-2 py-1 text-sm w-full" value={resp.answers[f.id]||''} onChange={e=> setAnswer(idx, f.id!, e.target.value)} />
|
||||
)}
|
||||
{f.type === 'numeric' && (
|
||||
<input type="number" className="border rounded px-2 py-1 text-sm w-full" value={resp.answers[f.id]||''} onChange={e=> setAnswer(idx, f.id!, e.target.value)} />
|
||||
)}
|
||||
{f.type === 'date' && (
|
||||
<input type="date" className="border rounded px-2 py-1 text-sm w-full" value={resp.answers[f.id]||''} onChange={e=> setAnswer(idx, f.id!, e.target.value)} />
|
||||
)}
|
||||
{f.type === 'yes_no' && (
|
||||
<select className="border rounded px-2 py-1 text-sm w-full" value={resp.answers[f.id]||''} onChange={e=> setAnswer(idx, f.id!, e.target.value)}>
|
||||
<option value="">Select…</option>
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
)}
|
||||
{f.helpText && <div className="text-[10px] text-gray-500 mt-0.5">{f.helpText}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
onClick={saveResponses}
|
||||
disabled={savingFill || mainTicketCount === 0}
|
||||
>
|
||||
{savingFill ? 'Saving…' : 'Save responses'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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'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 (24–48 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'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'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 <0821234567></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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user