Initial commit
Next.js + Express event management app for Hope Family Church.
This commit is contained in:
@@ -0,0 +1,505 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import type { EventCost, EventCostType, EventFinancials, CashupMethod } from "@/types";
|
||||
|
||||
const METHOD_LABELS: Record<CashupMethod, string> = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" };
|
||||
const METHODS: CashupMethod[] = ["cash", "card", "eft", "other"];
|
||||
const ZAR_DENOMINATIONS = [200, 100, 50, 20, 10, 5, 2, 1, 0.5, 0.2, 0.1];
|
||||
|
||||
function money(n: number | null | undefined): string {
|
||||
return `R${Number(n || 0).toFixed(2)}`;
|
||||
}
|
||||
function denomLabel(v: number): string {
|
||||
return v >= 1 ? `R${v}` : `${Math.round(v * 100)}c`;
|
||||
}
|
||||
|
||||
type LineInput = { actualAmount: string; notes: string };
|
||||
|
||||
export default function EventCashupPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const eventId = params.id;
|
||||
const router = useRouter();
|
||||
const { token } = useAuth();
|
||||
|
||||
const [tab, setTab] = useState<"costs" | "reconciliation">("costs");
|
||||
const [data, setData] = useState<EventFinancials | null>(null);
|
||||
const [eventOptions, setEventOptions] = useState<{ id: string; name: string }[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const isClosed = data?.event?.cashupStatus === "closed";
|
||||
|
||||
const load = async () => {
|
||||
if (!token || !eventId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [financials, event] = await Promise.all([
|
||||
apiFetch<EventFinancials>(`/api/cashups/event/${eventId}`, { authToken: token }),
|
||||
apiFetch<any>(`/api/events/${eventId}`, { authToken: token })
|
||||
]);
|
||||
setData(financials);
|
||||
setEventOptions((event.eventOptions || []).map((o: any) => ({ id: o.id, name: o.name })));
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load cashup data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [token, eventId]);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<button className="text-xs text-indigo-600 hover:underline" onClick={() => router.push("/dashboard/admin/cashup")}>← Back to cashup</button>
|
||||
<h1 className="text-xl font-semibold mt-1">{data?.event?.title || "Event"} — Cashup</h1>
|
||||
</div>
|
||||
<span className={"text-xs px-2 py-1 rounded " + (isClosed ? "bg-rose-50 text-rose-700" : "bg-emerald-50 text-emerald-700")}>
|
||||
{isClosed ? "Closed" : "Open"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-100 rounded p-2">{error}</div>}
|
||||
|
||||
<div className="flex gap-2 border-b">
|
||||
<button className={"px-3 py-2 text-sm " + (tab === "costs" ? "border-b-2 border-indigo-600 text-indigo-700 font-medium" : "text-gray-500")} onClick={() => setTab("costs")}>Costs</button>
|
||||
<button className={"px-3 py-2 text-sm " + (tab === "reconciliation" ? "border-b-2 border-indigo-600 text-indigo-700 font-medium" : "text-gray-500")} onClick={() => setTab("reconciliation")}>Reconciliation</button>
|
||||
</div>
|
||||
|
||||
{loading && <div className="text-sm text-gray-400">Loading…</div>}
|
||||
|
||||
{!loading && data && tab === "costs" && (
|
||||
<CostsTab eventId={eventId} token={token || ""} costs={data.costs} eventOptions={eventOptions} isClosed={isClosed} onChanged={load} />
|
||||
)}
|
||||
|
||||
{!loading && data && tab === "reconciliation" && (
|
||||
<ReconciliationTab
|
||||
eventId={eventId}
|
||||
token={token || ""}
|
||||
data={data}
|
||||
busy={busy}
|
||||
setBusy={setBusy}
|
||||
setError={setError}
|
||||
onChanged={load}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Costs tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }: {
|
||||
eventId: string; token: string; costs: EventCost[]; eventOptions: { id: string; name: string }[]; isClosed: boolean; onChanged: () => void;
|
||||
}) {
|
||||
const [editingId, setEditingId] = useState<string | "new" | null>(null);
|
||||
const [label, setLabel] = useState("");
|
||||
const [costType, setCostType] = useState<EventCostType>("once_off");
|
||||
const [amount, setAmount] = useState("");
|
||||
const [eventOptionId, setEventOptionId] = useState("");
|
||||
const [paidFromMethod, setPaidFromMethod] = useState<"" | CashupMethod>("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const startNew = () => {
|
||||
setEditingId("new");
|
||||
setLabel(""); setCostType("once_off"); setAmount(""); setEventOptionId(""); setPaidFromMethod(""); setNotes("");
|
||||
setErr(null);
|
||||
};
|
||||
|
||||
const startEdit = (c: EventCost) => {
|
||||
setEditingId(c.id);
|
||||
setLabel(c.label); setCostType(c.costType); setAmount(String(c.amount)); setEventOptionId(c.eventOptionId || ""); setPaidFromMethod(c.paidFromMethod || ""); setNotes(c.notes || "");
|
||||
setErr(null);
|
||||
};
|
||||
|
||||
const cancel = () => setEditingId(null);
|
||||
|
||||
const save = async () => {
|
||||
if (!label.trim()) { setErr("Label is required"); return; }
|
||||
if (!amount || isNaN(parseFloat(amount))) { setErr("Amount is required"); return; }
|
||||
if (costType === "per_item" && !eventOptionId) { setErr("Select a ticket type for per-item costs"); return; }
|
||||
setSaving(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const body = { label: label.trim(), costType, amount: parseFloat(amount), eventOptionId: costType === "per_item" ? eventOptionId : null, paidFromMethod: paidFromMethod || null, notes: notes || null };
|
||||
if (editingId === "new") {
|
||||
await apiFetch(`/api/events/${eventId}/costs`, { method: "POST", authToken: token, body });
|
||||
} else {
|
||||
await apiFetch(`/api/costs/${editingId}`, { method: "PUT", authToken: token, body });
|
||||
}
|
||||
setEditingId(null);
|
||||
onChanged();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Failed to save cost");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id: string) => {
|
||||
if (!confirm("Delete this cost?")) return;
|
||||
try {
|
||||
await apiFetch(`/api/costs/${id}`, { method: "DELETE", authToken: token });
|
||||
onChanged();
|
||||
} catch (e: any) {
|
||||
alert(e?.message || "Failed to delete cost");
|
||||
}
|
||||
};
|
||||
|
||||
const totalCosts = costs.reduce((sum, c) => sum + (c.total ?? c.amount), 0);
|
||||
|
||||
return (
|
||||
<div className="bg-white border rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">Event costs</div>
|
||||
{!isClosed && editingId === null && (
|
||||
<button className="text-xs px-2 py-1 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={startNew}>+ Add cost</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isClosed && <div className="text-xs text-gray-500">This event is closed — costs can't be changed until it's reopened.</div>}
|
||||
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 border-b">
|
||||
<th className="py-1">Label</th>
|
||||
<th className="py-1">Type</th>
|
||||
<th className="py-1">Ticket type</th>
|
||||
<th className="py-1">Paid from</th>
|
||||
<th className="py-1 text-right">Amount</th>
|
||||
<th className="py-1 text-right">Total</th>
|
||||
{!isClosed && <th className="py-1"></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{costs.map(c => (
|
||||
<tr key={c.id} className="border-b last:border-0">
|
||||
<td className="py-1.5">{c.label}</td>
|
||||
<td className="py-1.5">{c.costType === "once_off" ? "Once-off" : "Per item"}</td>
|
||||
<td className="py-1.5">{c.eventOption?.name || "—"}</td>
|
||||
<td className="py-1.5 capitalize">{c.paidFromMethod || "—"}</td>
|
||||
<td className="py-1.5 text-right">{money(c.amount)}</td>
|
||||
<td className="py-1.5 text-right font-medium">{money(c.total ?? c.amount)}</td>
|
||||
{!isClosed && (
|
||||
<td className="py-1.5 text-right whitespace-nowrap">
|
||||
<button className="text-xs text-indigo-600 hover:underline mr-2" onClick={() => startEdit(c)}>Edit</button>
|
||||
<button className="text-xs text-red-600 hover:underline" onClick={() => remove(c.id)}>Delete</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{costs.length === 0 && (
|
||||
<tr><td colSpan={7} className="py-3 text-gray-400 text-center">No costs added yet.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
{costs.length > 0 && (
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colSpan={5} className="pt-2 text-right text-gray-500">Total costs</td>
|
||||
<td className="pt-2 text-right font-semibold">{money(totalCosts)}</td>
|
||||
{!isClosed && <td />}
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
|
||||
{editingId !== null && (
|
||||
<div className="border rounded p-3 space-y-2 bg-gray-50">
|
||||
{err && <div className="text-xs text-red-600">{err}</div>}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Label</label>
|
||||
<input className="w-full border rounded px-2 py-1.5 text-sm" value={label} onChange={e => setLabel(e.target.value)} placeholder="e.g. Venue hire" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Type</label>
|
||||
<select className="w-full border rounded px-2 py-1.5 text-sm" value={costType} onChange={e => setCostType(e.target.value as EventCostType)}>
|
||||
<option value="once_off">Once-off / overall</option>
|
||||
<option value="per_item">Per item (ticket type)</option>
|
||||
</select>
|
||||
</div>
|
||||
{costType === "per_item" && (
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Ticket type</label>
|
||||
<select className="w-full border rounded px-2 py-1.5 text-sm" value={eventOptionId} onChange={e => setEventOptionId(e.target.value)}>
|
||||
<option value="">Select…</option>
|
||||
{eventOptions.map(o => <option key={o.id} value={o.id}>{o.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Amount {costType === "per_item" ? "(per ticket)" : "(flat total)"}</label>
|
||||
<input type="number" step="0.01" className="w-full border rounded px-2 py-1.5 text-sm" value={amount} onChange={e => setAmount(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Paid from (optional)</label>
|
||||
<select className="w-full border rounded px-2 py-1.5 text-sm" value={paidFromMethod} onChange={e => setPaidFromMethod(e.target.value as any)}>
|
||||
<option value="">Not from event takings</option>
|
||||
{METHODS.map(m => <option key={m} value={m}>{METHOD_LABELS[m]}</option>)}
|
||||
</select>
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">If this was paid out of the door takings (e.g. cash to a vendor), tag it so the Cashup report deducts it from that method's expected amount.</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs text-gray-600 mb-1">Notes (optional)</label>
|
||||
<input className="w-full border rounded px-2 py-1.5 text-sm" value={notes} onChange={e => setNotes(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button className="text-xs px-3 py-1.5 rounded border" onClick={cancel} disabled={saving}>Cancel</button>
|
||||
<button className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={save} disabled={saving}>{saving ? "Saving…" : "Save"}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Reconciliation tab ─────────────────────────────────────────────────────
|
||||
|
||||
function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onChanged }: {
|
||||
eventId: string; token: string; data: EventFinancials; busy: boolean;
|
||||
setBusy: (b: boolean) => void; setError: (e: string | null) => void; onChanged: () => void;
|
||||
}) {
|
||||
const isClosed = data.event.cashupStatus === "closed";
|
||||
const draftLines = (data.event as any).cashupDraft?.lines as Array<{ method: string; actualAmount?: string | number; notes?: string; denominations?: { value: number; count: number }[] }> | undefined;
|
||||
|
||||
const initialLines: Record<CashupMethod, LineInput> = useMemo(() => {
|
||||
const base: Record<CashupMethod, LineInput> = { cash: { actualAmount: "", notes: "" }, card: { actualAmount: "", notes: "" }, eft: { actualAmount: "", notes: "" }, other: { actualAmount: "", notes: "" } };
|
||||
for (const l of draftLines || []) {
|
||||
if (l.method in base) base[l.method as CashupMethod] = { actualAmount: l.actualAmount != null ? String(l.actualAmount) : "", notes: l.notes || "" };
|
||||
}
|
||||
return base;
|
||||
}, [draftLines]);
|
||||
|
||||
const initialDenomCounts: Record<number, string> = useMemo(() => {
|
||||
const cashLine = (draftLines || []).find(l => l.method === "cash");
|
||||
const out: Record<number, string> = {};
|
||||
for (const d of cashLine?.denominations || []) out[d.value] = String(d.count);
|
||||
return out;
|
||||
}, [draftLines]);
|
||||
|
||||
const [lines, setLines] = useState<Record<CashupMethod, LineInput>>(initialLines);
|
||||
const [denomCounts, setDenomCounts] = useState<Record<number, string>>(initialDenomCounts);
|
||||
const [closeNotes, setCloseNotes] = useState("");
|
||||
const [reopenNotes, setReopenNotes] = useState("");
|
||||
|
||||
useEffect(() => { setLines(initialLines); setDenomCounts(initialDenomCounts); }, [initialLines, initialDenomCounts]);
|
||||
|
||||
const setLine = (method: CashupMethod, field: keyof LineInput, value: string) => {
|
||||
setLines(prev => ({ ...prev, [method]: { ...prev[method], [field]: value } }));
|
||||
};
|
||||
|
||||
const cashDenominationsPayload = () => ZAR_DENOMINATIONS
|
||||
.map(value => ({ value, count: parseInt(denomCounts[value] || "0", 10) || 0 }))
|
||||
.filter(d => d.count > 0);
|
||||
|
||||
const cashActualFromDenoms = cashDenominationsPayload().reduce((sum, d) => sum + d.value * d.count, 0);
|
||||
|
||||
const buildLinesPayload = () => METHODS.map(m => m === "cash"
|
||||
? { method: "cash", denominations: cashDenominationsPayload(), notes: lines.cash.notes || null }
|
||||
: { method: m, actualAmount: lines[m].actualAmount === "" ? null : parseFloat(lines[m].actualAmount), notes: lines[m].notes || null });
|
||||
|
||||
const saveDraft = async () => {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/cashups/event/${eventId}/draft`, { method: "PUT", authToken: token, body: { lines: buildLinesPayload() } });
|
||||
onChanged();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to save draft");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeWithCashup = async () => {
|
||||
if (!confirm("Close this event with the entered reconciliation? This will count unallocated donations as profit and fully lock the event until it's reopened.")) return;
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/cashups/event/${eventId}/close`, { method: "POST", authToken: token, body: { lines: buildLinesPayload(), notes: closeNotes || null } });
|
||||
onChanged();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to close event");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const quickClose = async () => {
|
||||
if (!confirm("Quick close this event without a per-method cashup? System totals will be accepted as-is, unallocated donations will be counted as profit, and the event will be fully locked until it's reopened.")) return;
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/cashups/event/${eventId}/close`, { method: "POST", authToken: token, body: { notes: closeNotes || null } });
|
||||
onChanged();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to close event");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const reopen = async () => {
|
||||
if (!confirm("Reopen this event? Registrations, payments, refunds and donations will be allowed again.")) return;
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/cashups/event/${eventId}/reopen`, { method: "POST", authToken: token, body: { notes: reopenNotes || null } });
|
||||
onChanged();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to reopen event");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const reconciled = data.reconciled;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white border rounded-lg p-4 space-y-3 overflow-auto">
|
||||
<div className="text-sm font-medium">Cash-up reconciliation</div>
|
||||
<table className="w-full text-sm min-w-[640px]">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 border-b">
|
||||
<th className="py-1">Method</th>
|
||||
<th className="py-1 text-right">Income</th>
|
||||
<th className="py-1 text-right">Costs from method</th>
|
||||
<th className="py-1 text-right">Expected cash</th>
|
||||
<th className="py-1 text-right">Actual</th>
|
||||
<th className="py-1 text-right">Variance</th>
|
||||
<th className="py-1">Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{METHODS.map(m => {
|
||||
const r = reconciled?.byMethod?.[m];
|
||||
return (
|
||||
<tr key={m} className="border-b last:border-0 align-top">
|
||||
<td className="py-1.5">{METHOD_LABELS[m]}</td>
|
||||
<td className="py-1.5 text-right">{money(data.paymentsByMethod[m])}</td>
|
||||
<td className="py-1.5 text-right">{money(data.costsByMethod[m])}</td>
|
||||
<td className="py-1.5 text-right">{money(data.expectedCashByMethod[m])}</td>
|
||||
<td className="py-1.5 text-right">
|
||||
{isClosed ? (
|
||||
money(r?.actual ?? null)
|
||||
) : m === "cash" ? (
|
||||
money(cashActualFromDenoms)
|
||||
) : (
|
||||
<input type="number" step="0.01" className="w-28 border rounded px-2 py-1 text-sm text-right" value={lines[m].actualAmount} onChange={e => setLine(m, "actualAmount", e.target.value)} />
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 text-right">
|
||||
{isClosed
|
||||
? (r?.variance != null ? money(r.variance) : <span className="text-gray-400">not reconciled</span>)
|
||||
: (m === "cash"
|
||||
? (cashActualFromDenoms > 0 ? money(cashActualFromDenoms - data.expectedCashByMethod[m]) : "—")
|
||||
: (lines[m].actualAmount !== "" ? money(parseFloat(lines[m].actualAmount) - data.expectedCashByMethod[m]) : "—"))}
|
||||
</td>
|
||||
<td className="py-1.5">
|
||||
{isClosed ? (r?.notes || "—") : (
|
||||
<input className="w-full border rounded px-2 py-1 text-sm" value={lines[m].notes} onChange={e => setLine(m, "notes", e.target.value)} />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border rounded-lg p-4 space-y-2">
|
||||
<div className="text-sm font-medium">Cash denomination count</div>
|
||||
{isClosed ? (
|
||||
reconciled?.byMethod?.cash?.denominations?.length ? (
|
||||
<table className="text-sm">
|
||||
<tbody>
|
||||
{reconciled.byMethod.cash.denominations.map(d => (
|
||||
<tr key={d.id}><td className="pr-4 py-0.5">{denomLabel(d.value)}</td><td className="pr-4 py-0.5">× {d.count}</td><td className="py-0.5 text-gray-500">{money(d.value * d.count)}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : <div className="text-xs text-gray-400">No denomination breakdown recorded for this cashup.</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
{ZAR_DENOMINATIONS.map(v => (
|
||||
<div key={v} className="flex items-center gap-2">
|
||||
<span className="text-sm w-14">{denomLabel(v)}</span>
|
||||
<span className="text-xs text-gray-400">×</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
className="w-16 border rounded px-2 py-1 text-sm"
|
||||
value={denomCounts[v] || ""}
|
||||
onChange={e => setDenomCounts(prev => ({ ...prev, [v]: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white border rounded-lg p-4 grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">Donations counted as profit</div>
|
||||
<div className="font-semibold">{money(data.unallocatedDonationsTotal)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">Total costs</div>
|
||||
<div className="font-semibold">{money(data.totalCosts)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">Net profit</div>
|
||||
<div className="font-semibold">{money(data.netProfit)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isClosed && (
|
||||
<div className="bg-white border rounded-lg p-4 space-y-2">
|
||||
<label className="block text-xs text-gray-600 mb-1">Notes for closing (optional)</label>
|
||||
<input className="w-full border rounded px-2 py-1.5 text-sm" value={closeNotes} onChange={e => setCloseNotes(e.target.value)} />
|
||||
<div className="flex flex-wrap gap-2 justify-end pt-1">
|
||||
<button className="text-xs px-3 py-1.5 rounded border" disabled={busy} onClick={saveDraft}>Save draft</button>
|
||||
<button className="text-xs px-3 py-1.5 rounded bg-amber-600 text-white hover:bg-amber-700" disabled={busy} onClick={quickClose}>Quick close (skip cashup)</button>
|
||||
<button className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" disabled={busy} onClick={closeWithCashup}>Close event with cashup</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isClosed && (
|
||||
<div className="bg-white border rounded-lg p-4 space-y-2">
|
||||
<label className="block text-xs text-gray-600 mb-1">Notes for reopening (optional)</label>
|
||||
<input className="w-full border rounded px-2 py-1.5 text-sm" value={reopenNotes} onChange={e => setReopenNotes(e.target.value)} />
|
||||
<div className="flex justify-end">
|
||||
<button className="text-xs px-3 py-1.5 rounded bg-rose-600 text-white hover:bg-rose-700" disabled={busy} onClick={reopen}>Reopen event</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white border rounded-lg p-4 space-y-2">
|
||||
<div className="text-sm font-medium">History</div>
|
||||
{data.history.length === 0 && <div className="text-xs text-gray-400">No close/reopen actions yet.</div>}
|
||||
<ul className="space-y-1.5">
|
||||
{data.history.map(h => (
|
||||
<li key={h.id} className="text-xs border-b last:border-0 pb-1.5 flex items-center justify-between gap-3">
|
||||
<span>
|
||||
<span className="font-medium">{h.action === "closed" ? "Closed (full cashup)" : h.action === "quick_closed" ? "Quick closed" : "Reopened"}</span>
|
||||
{" — "}{h.performedBy?.name || "Unknown"} — {new Date(h.createdAt).toLocaleString()}
|
||||
</span>
|
||||
{(h.action === "closed" || h.action === "quick_closed") && (
|
||||
<span className="text-gray-500 shrink-0">Donations to profit {money(h.unallocatedDonationsTotal)} · Costs {money(h.totalCosts)}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
export default function CashupLandingPage() {
|
||||
const { token } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [showPast, setShowPast] = useState(true);
|
||||
const [showInactive, setShowInactive] = useState(false);
|
||||
const [showClosed, setShowClosed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const evs = await apiFetch<any[]>("/api/events/all?includePast=true&includeInactive=true", { authToken: token });
|
||||
setEvents(Array.isArray(evs) ? evs : []);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load events");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [token]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const now = new Date();
|
||||
return events
|
||||
.filter(ev => showPast || !ev.endDate || new Date(ev.endDate) >= now)
|
||||
.filter(ev => showInactive || ev.isActive !== false)
|
||||
.filter(ev => showClosed || ev.cashupStatus !== "closed")
|
||||
.filter(ev => !search.trim() || ev.title?.toLowerCase().includes(search.trim().toLowerCase()))
|
||||
.sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
|
||||
}, [events, showPast, showInactive, showClosed, search]);
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Post-event Cashup</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Set costs, reconcile takings, and close out an event. Admin only.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shrink-0"
|
||||
onClick={() => router.push("/dashboard")}
|
||||
>Back</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-100 rounded p-2">{error}</div>}
|
||||
|
||||
<div className="bg-white border rounded-lg p-3 flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
className="border rounded px-3 py-1.5 text-sm flex-1 min-w-48"
|
||||
placeholder="Search events…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
<label className="flex items-center gap-1.5 text-sm text-gray-600">
|
||||
<input type="checkbox" checked={showPast} onChange={e => setShowPast(e.target.checked)} /> Past events
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-sm text-gray-600">
|
||||
<input type="checkbox" checked={showInactive} onChange={e => setShowInactive(e.target.checked)} /> Inactive events
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-sm text-gray-600">
|
||||
<input type="checkbox" checked={showClosed} onChange={e => setShowClosed(e.target.checked)} /> Closed events
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{loading && <div className="text-sm text-gray-400">Loading…</div>}
|
||||
|
||||
{!loading && (
|
||||
<ul className="space-y-2">
|
||||
{filtered.map(ev => {
|
||||
const isClosed = ev.cashupStatus === "closed";
|
||||
return (
|
||||
<li
|
||||
key={ev.id}
|
||||
className="border rounded-lg p-3 bg-white hover:bg-indigo-50/40 cursor-pointer transition-colors flex items-center justify-between gap-3"
|
||||
onClick={() => router.push(`/dashboard/admin/cashup/${ev.id}`)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-sm">{ev.title}</span>
|
||||
<span className={"text-[10px] px-1.5 py-0.5 rounded " + (isClosed ? "bg-rose-50 text-rose-700" : "bg-emerald-50 text-emerald-700")}>
|
||||
{isClosed ? "Closed" : "Open"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">
|
||||
{ev.startDate ? new Date(ev.startDate).toLocaleDateString() : ""}{ev.endDate ? ` – ${new Date(ev.endDate).toLocaleDateString()}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-indigo-600 shrink-0">Manage →</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{filtered.length === 0 && <div className="text-sm text-gray-400">No events match the current filters.</div>}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import FormsBrowserPage from "@/app/dashboard/supervisor/forms/page";
|
||||
|
||||
export default function AdminFormsBrowserPage() {
|
||||
// Reuse the same component; admin also has access
|
||||
return <FormsBrowserPage />;
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
"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 AdminDashboardPage() {
|
||||
const { user, loading, token } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const isAdmin = useMemo(() => (user?.role === "admin"), [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!user) router.replace("/login");
|
||||
}, [user, loading, router]);
|
||||
|
||||
// Everything this dashboard displays comes from one endpoint (/api/stats/admin) 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 the re-render entirely when a poll returns identical data, and
|
||||
// hasLoadedOnce below means "Refreshing…" only ever shows for the very first load —
|
||||
// together these stop the stats panels from flickering on every 15s poll.
|
||||
const [paymentStats, setPaymentStats] = useStableState<any | null>(null);
|
||||
const [scanStats, setScanStats] = 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/admin", { authToken: token });
|
||||
setScanStats(data.scanStats);
|
||||
setPaymentStats(data.paymentStats);
|
||||
setActiveEventsCount(data.activeEventsCount || 0);
|
||||
setRecentScans(Array.isArray(data.recentScans) ? data.recentScans : []);
|
||||
} catch (e) {
|
||||
// ignore errors for dashboard summaries
|
||||
} 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">Admin 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/admin/users")}>Manage users</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-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/admin/registrations")}>Manage registrations</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/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/payments")}>Payments</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/admin/whatsapp")}>WhatsApp API</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isAdmin && (
|
||||
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
|
||||
You need 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/admin/users")}>
|
||||
Manage users
|
||||
<div className="text-xs text-white/90">Create, edit, change roles and passwords</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-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/admin/registrations")}>
|
||||
Manage registrations
|
||||
<div className="text-xs text-white/90">Cancel, update status, and search registrations</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/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/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-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/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/admin/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>
|
||||
<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/admin/whatsapp")}>
|
||||
Manage WhatsApp API
|
||||
<div className="text-xs text-white/90">Manage the WhatsApp API config</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/admin/cashup")}>
|
||||
Post-event Cashup
|
||||
<div className="text-xs text-white/90">Set costs, reconcile takings, and close out events</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">Payments</h2>
|
||||
{loadingStats && <span className="text-xs text-gray-500">Refreshing…</span>}
|
||||
</div>
|
||||
{paymentStats ? (
|
||||
<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">R{paymentStats.totalToday}</div>
|
||||
</div>
|
||||
<div className="border rounded p-3 bg-white">
|
||||
<div className="text-xs text-gray-500">Past Week</div>
|
||||
<div className="text-lg font-semibold">R{paymentStats.totalWeek}</div>
|
||||
</div>
|
||||
<div className="border rounded p-3 bg-white">
|
||||
<div className="text-xs text-gray-500">Past Month</div>
|
||||
<div className="text-lg font-semibold">R{paymentStats.totalMonth}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500">No payment 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">Admin 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 className="text-sm text-gray-600 mt-6">
|
||||
<p>As an admin you can access Supervisor and Staff tools. Use the quick actions above to jump to common tasks.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
const STATUS_OPTIONS = ["pending", "confirmed", "partial_paid", "paid", "cancelled"] as const;
|
||||
|
||||
function fuzzyMatch(query: string, target: string): boolean {
|
||||
const q = query.toLowerCase();
|
||||
const t = target.toLowerCase();
|
||||
if (t.includes(q)) return true;
|
||||
const tokens = q.split(/\s+/).filter(Boolean);
|
||||
return tokens.every(tok => t.includes(tok));
|
||||
}
|
||||
|
||||
export default function AdminRegistrationsPage() {
|
||||
const { user, loading, token } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const isAdmin = useMemo(() => user?.role === "admin", [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!user) router.replace("/login");
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [registrations, setRegistrations] = useState<any[]>([]);
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [loadingRegs, setLoadingRegs] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
|
||||
// Filters
|
||||
const [query, setQuery] = useState("");
|
||||
const [eventFilter, setEventFilter] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [includePastEvents, setIncludePastEvents] = useState(false);
|
||||
const [includeInactiveEvents, setIncludeInactiveEvents] = useState(false);
|
||||
|
||||
// Expanded rows + form responses cache
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [formResponses, setFormResponses] = useState<Record<string, any[]>>({});
|
||||
const [loadingForms, setLoadingForms] = useState<Set<string>>(new Set());
|
||||
|
||||
const loadRegistrations = async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
setLoadingRegs(true);
|
||||
const regs = await apiFetch<any[]>("/api/registrations", { authToken: token });
|
||||
const list = Array.isArray(regs) ? regs.sort((a, b) => {
|
||||
const eventCompare = (a.event?.startDate || a.eventId).localeCompare(b.event?.startDate || b.eventId);
|
||||
if (eventCompare !== 0) return eventCompare;
|
||||
return (a.user?.name || a.userId).localeCompare(b.user?.name || b.userId);
|
||||
}) : [];
|
||||
setRegistrations(list);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load registrations");
|
||||
} finally {
|
||||
setLoadingRegs(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadEvents = async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (includePastEvents) params.set("includePast", "true");
|
||||
if (includeInactiveEvents) params.set("includeInactive", "true");
|
||||
const qs = params.toString() ? `?${params.toString()}` : "";
|
||||
const evs = await apiFetch<any[]>(`/api/events/all${qs}`, { authToken: token });
|
||||
setEvents(Array.isArray(evs) ? evs.sort((a: any, b: any) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime()) : []);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadRegistrations();
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
loadEvents();
|
||||
}, [token, includePastEvents, includeInactiveEvents]);
|
||||
|
||||
const toggleExpand = async (reg: any) => {
|
||||
const id = reg.id;
|
||||
const next = new Set(expanded);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
setExpanded(next);
|
||||
return;
|
||||
}
|
||||
next.add(id);
|
||||
setExpanded(next);
|
||||
// Load form responses if not cached
|
||||
if (!formResponses[id] && !loadingForms.has(id)) {
|
||||
setLoadingForms(prev => new Set(prev).add(id));
|
||||
try {
|
||||
const res = await apiFetch<any>(`/api/forms/responses?registrationId=${encodeURIComponent(id)}`, { authToken: token! });
|
||||
const items = Array.isArray(res?.items) ? res.items : (Array.isArray(res) ? res : []);
|
||||
setFormResponses(prev => ({ ...prev, [id]: items }));
|
||||
} catch {
|
||||
setFormResponses(prev => ({ ...prev, [id]: [] }));
|
||||
} finally {
|
||||
setLoadingForms(prev => { const s = new Set(prev); s.delete(id); return s; });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cancelRegistration = async (reg: any) => {
|
||||
if (!token) return;
|
||||
setError(null); setInfo(null);
|
||||
if (!confirm(`Cancel registration #${String(reg.id).slice(0, 8)} for ${reg.user?.name || reg.userId}?`)) return;
|
||||
try {
|
||||
await apiFetch(`/api/registrations/${encodeURIComponent(reg.id)}`, { method: "DELETE", authToken: token });
|
||||
setInfo("Registration cancelled");
|
||||
await loadRegistrations();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to cancel registration");
|
||||
}
|
||||
};
|
||||
|
||||
const updateStatus = async (reg: any, status: string) => {
|
||||
if (!token) return;
|
||||
setError(null); setInfo(null);
|
||||
try {
|
||||
await apiFetch(`/api/registrations/${encodeURIComponent(reg.id)}`, { method: "PUT", authToken: token, body: { status } });
|
||||
setInfo("Status updated");
|
||||
await loadRegistrations();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to update status");
|
||||
}
|
||||
};
|
||||
|
||||
const eventIds = useMemo(() => new Set(events.map((ev: any) => ev.id)), [events]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return registrations.filter((r: any) => {
|
||||
if (!eventIds.has(r.eventId)) return false;
|
||||
if (eventFilter && r.eventId !== eventFilter) return false;
|
||||
if (statusFilter && r.status !== statusFilter) return false;
|
||||
if (!query.trim()) return true;
|
||||
const haystack = [
|
||||
r.id, r.user?.name, r.user?.email, r.user?.phoneNumber,
|
||||
r.userId, r.event?.title, r.eventId, r.status,
|
||||
].map((x: any) => String(x || "")).join(" ");
|
||||
return fuzzyMatch(query.trim(), haystack);
|
||||
});
|
||||
}, [registrations, query, eventFilter, statusFilter]);
|
||||
|
||||
const statusColor = (s: string) => {
|
||||
if (s === "paid") return "text-green-700 bg-green-50";
|
||||
if (s === "confirmed") return "text-blue-700 bg-blue-50";
|
||||
if (s === "partial_paid") return "text-amber-700 bg-amber-50";
|
||||
if (s === "cancelled") return "text-red-700 bg-red-50";
|
||||
return "text-gray-700 bg-gray-50";
|
||||
};
|
||||
|
||||
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">Manage Registrations</h1>
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={() => router.push('/dashboard')}>Back</button>
|
||||
</div>
|
||||
|
||||
{!isAdmin && (
|
||||
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
|
||||
You need 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>}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm mb-4">
|
||||
<div className="flex flex-wrap gap-3 items-end">
|
||||
<div className="flex-1 min-w-48">
|
||||
<label className="block text-xs text-gray-600 mb-1">Search (name, email, phone, event, ID…)</label>
|
||||
<input
|
||||
className="w-full border rounded px-3 py-1.5 text-sm"
|
||||
placeholder="Type to search…"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Event</label>
|
||||
<select className="border rounded px-2 py-1.5 text-sm max-w-48" value={eventFilter} onChange={e => setEventFilter(e.target.value)}>
|
||||
<option value="">All events</option>
|
||||
{events.map(ev => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-500">
|
||||
<label className="flex items-center gap-1 cursor-pointer">
|
||||
<input type="checkbox" checked={includePastEvents} onChange={e => setIncludePastEvents(e.target.checked)} />
|
||||
Past
|
||||
</label>
|
||||
<label className="flex items-center gap-1 cursor-pointer">
|
||||
<input type="checkbox" checked={includeInactiveEvents} onChange={e => setIncludeInactiveEvents(e.target.checked)} />
|
||||
Inactive
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Status</label>
|
||||
<select className="border rounded px-2 py-1.5 text-sm" value={statusFilter} onChange={e => setStatusFilter(e.target.value)}>
|
||||
<option value="">All statuses</option>
|
||||
{STATUS_OPTIONS.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button className="text-sm px-2 py-1.5 rounded bg-gray-100 hover:bg-gray-200" onClick={loadRegistrations} disabled={loadingRegs}>
|
||||
{loadingRegs ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500">{filtered.length} of {registrations.length} registrations</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-xl bg-white shadow-sm">
|
||||
<ul className="divide-y text-sm">
|
||||
{filtered.map((r: any) => {
|
||||
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => {
|
||||
const unit = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
|
||||
? Number(opt.priceSnapshot)
|
||||
: (opt.eventOption?.price || 0);
|
||||
return sum + unit * (opt.quantity || 0);
|
||||
}, 0);
|
||||
const isExpanded = expanded.has(r.id);
|
||||
const responses = formResponses[r.id];
|
||||
const loadingResponse = loadingForms.has(r.id);
|
||||
|
||||
return (
|
||||
<li key={r.id} className="hover:bg-gray-50">
|
||||
<div className="p-3 cursor-pointer" onClick={() => toggleExpand(r)}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium">{r.user?.name || r.userId}</span>
|
||||
<span className="text-gray-400">—</span>
|
||||
<span className="text-gray-700">{r.event?.title || r.eventId}</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${statusColor(r.status)}`}>{r.status}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">
|
||||
{r.user?.email && <span className="mr-2">{r.user.email}</span>}
|
||||
{r.user?.phoneNumber && <span className="mr-2">{r.user.phoneNumber}</span>}
|
||||
<span>R {totalDue.toFixed(2)}</span>
|
||||
<span className="ml-2 text-gray-400">#{String(r.id).slice(0, 8)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-xs text-gray-400">{new Date(r.createdAt).toLocaleDateString()}</span>
|
||||
<span className="text-gray-400 text-xs">{isExpanded ? "▲" : "▼"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 bg-gray-50 border-t" onClick={e => e.stopPropagation()}>
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 py-2 border-b border-gray-200 mb-3">
|
||||
<select
|
||||
className="px-2 py-1 text-xs rounded-lg border border-gray-300 bg-white shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-60"
|
||||
value={r.status}
|
||||
onChange={e => updateStatus(r, e.target.value)}
|
||||
disabled={r.status === 'cancelled'}
|
||||
>
|
||||
{STATUS_OPTIONS.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<button
|
||||
className="px-2 py-1 text-xs rounded bg-red-600 text-white hover:bg-red-700 disabled:opacity-50"
|
||||
onClick={() => cancelRegistration(r)}
|
||||
disabled={r.status === 'cancelled'}
|
||||
>
|
||||
Cancel registration
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Ticket options */}
|
||||
{(r.registrationOptions || []).length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="text-xs font-semibold text-gray-600 mb-1 uppercase tracking-wide">Ticket options</div>
|
||||
<div className="grid sm:grid-cols-2 gap-2">
|
||||
{r.registrationOptions.map((opt: any) => (
|
||||
<div key={opt.id} className="bg-white border rounded p-2 text-xs">
|
||||
<div className="font-medium">
|
||||
{opt.eventOption?.name || opt.eventOptionId}
|
||||
{opt.variant?.name && <span className="text-gray-500"> ({opt.variant.name})</span>}
|
||||
</div>
|
||||
<div className="text-gray-500">
|
||||
{(() => {
|
||||
const unit = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
|
||||
? Number(opt.priceSnapshot)
|
||||
: (opt.variant?.price ?? opt.eventOption?.price ?? 0);
|
||||
return `Qty: ${opt.quantity} × R ${unit.toFixed(2)} = R ${(unit * (opt.quantity || 0)).toFixed(2)}`;
|
||||
})()}
|
||||
</div>
|
||||
{opt.appliedTierId && (
|
||||
<div className="text-green-700 text-[10px] mt-0.5">Early-bird price applied</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-gray-700 mt-1 font-medium">Total: R {totalDue.toFixed(2)}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form responses */}
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-gray-600 mb-1 uppercase tracking-wide">Form responses</div>
|
||||
{loadingResponse ? (
|
||||
<div className="text-xs text-gray-400">Loading…</div>
|
||||
) : !responses || responses.length === 0 ? (
|
||||
<div className="text-xs text-gray-400">No form responses submitted.</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{responses.map((resp: any, idx: number) => (
|
||||
<div key={resp.id || idx} className="bg-white border rounded p-2">
|
||||
<div className="text-xs font-medium text-gray-600 mb-1">Response #{idx + 1}</div>
|
||||
<div className="grid sm:grid-cols-2 gap-1.5">
|
||||
{(resp.answers || []).map((a: any) => (
|
||||
<div key={a.id} className="bg-gray-50 border rounded p-1.5 text-xs">
|
||||
<div className="text-[10px] text-gray-500">{a.field?.label || a.fieldId}</div>
|
||||
<div className="font-medium">{a.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="mt-2 text-[10px] text-gray-400">
|
||||
Registration ID: {r.id} · Created: {new Date(r.createdAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{filtered.length === 0 && (
|
||||
<li className="p-4 text-gray-500 text-sm">{loadingRegs ? "Loading…" : "No registrations found."}</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch, API_BASE, resolveToApiOrigin } from "@/lib/api";
|
||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
|
||||
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal";
|
||||
|
||||
const TABS: { id: TabId; label: string }[] = [
|
||||
{ id: "organisation", label: "Organisation" },
|
||||
{ id: "branding", label: "Branding" },
|
||||
{ id: "notifications", label: "Notifications" },
|
||||
{ id: "email", label: "Email" },
|
||||
{ id: "legal", label: "Legal" },
|
||||
];
|
||||
|
||||
const inputCls =
|
||||
"w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400";
|
||||
|
||||
function Field({
|
||||
label, hint, required, children,
|
||||
}: {
|
||||
label: string; hint?: string; required?: boolean; children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{label} {required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
{children}
|
||||
{hint && <p className="text-xs text-gray-400 mt-1">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveBar({
|
||||
saving, onSave, result, onDismiss,
|
||||
}: {
|
||||
saving: boolean;
|
||||
onSave: () => void;
|
||||
result: { ok: boolean; message: string } | null;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between pt-4 border-t mt-6 flex-wrap gap-3">
|
||||
{result ? (
|
||||
<span className={`text-sm flex items-center gap-1.5 ${result.ok ? "text-green-600" : "text-red-600"}`}>
|
||||
{result.ok ? "✓" : "✗"} {result.message}
|
||||
<button type="button" onClick={onDismiss} className="ml-1 text-gray-400 hover:text-gray-600 text-xs">×</button>
|
||||
</span>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={onSave}
|
||||
className="px-6 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SiteSettingsPage() {
|
||||
const { token } = useAuth();
|
||||
const router = useRouter();
|
||||
const { reload: reloadSettings } = useSiteSettings();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabId>("organisation");
|
||||
const [loadingInitial, setLoadingInitial] = useState(true);
|
||||
|
||||
// Per-tab save state
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
|
||||
// ── Organisation ──
|
||||
const [orgName, setOrgName] = useState("");
|
||||
const [orgTagline, setOrgTagline] = useState("");
|
||||
const [orgEmail, setOrgEmail] = useState("");
|
||||
const [orgPhone, setOrgPhone] = useState("");
|
||||
const [orgAddress, setOrgAddress] = useState("");
|
||||
const [appBaseUrl, setAppBaseUrl] = useState("");
|
||||
|
||||
// ── Branding ──
|
||||
const [accentColor, setAccentColor] = useState("#2563eb");
|
||||
const [logoUrl, setLogoUrl] = useState("");
|
||||
const [logoFile, setLogoFile] = useState<File | null>(null);
|
||||
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// ── Notifications ──
|
||||
const [notifEmails, setNotifEmails] = useState("");
|
||||
|
||||
// ── SMTP ──
|
||||
const [smtpHost, setSmtpHost] = useState("");
|
||||
const [smtpPort, setSmtpPort] = useState("587");
|
||||
const [smtpSecure, setSmtpSecure] = useState(false);
|
||||
const [smtpFrom, setSmtpFrom] = useState("");
|
||||
const [smtpUser, setSmtpUser] = useState("");
|
||||
const [smtpPass, setSmtpPass] = useState("");
|
||||
const [smtpPassSet, setSmtpPassSet] = useState(false);
|
||||
const [smtpTesting, setSmtpTesting] = useState(false);
|
||||
const [smtpTestResult, setSmtpTestResult] = useState<{ ok: boolean; message: string; raw?: string } | null>(null);
|
||||
|
||||
// ── Legal ──
|
||||
const [legalOperatorName, setLegalOperatorName] = useState("");
|
||||
const [legalIoName, setLegalIoName] = useState("");
|
||||
const [legalIoEmail, setLegalIoEmail] = useState("");
|
||||
const [legalWebsiteUrl, setLegalWebsiteUrl] = useState("");
|
||||
const [legalEffectiveDate, setLegalEffectiveDate] = useState("");
|
||||
|
||||
// ── Load all settings once ────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<Record<string, string>>("/api/settings/all", { authToken: token })
|
||||
.then((s) => {
|
||||
setOrgName(s.org_name || "");
|
||||
setOrgTagline(s.org_tagline || "");
|
||||
setOrgEmail(s.org_email || "");
|
||||
setOrgPhone(s.org_phone || "");
|
||||
setOrgAddress(s.org_address || "");
|
||||
setAppBaseUrl(s.app_base_url || "");
|
||||
setAccentColor(s.accent_color || "#2563eb");
|
||||
setLogoUrl(s.logo_url || "");
|
||||
setNotifEmails(s.reg_notification_emails || "");
|
||||
setSmtpHost(s.smtp_host || "");
|
||||
setSmtpPort(s.smtp_port || "587");
|
||||
setSmtpSecure((s.smtp_secure || "").toLowerCase() === "true");
|
||||
setSmtpFrom(s.smtp_from || "");
|
||||
setSmtpUser(s.smtp_user || "");
|
||||
setSmtpPassSet(!!s.smtp_pass && s.smtp_pass !== "");
|
||||
setSmtpPass("");
|
||||
setLegalOperatorName(s.legal_operator_name || "");
|
||||
setLegalIoName(s.legal_io_name || "");
|
||||
setLegalIoEmail(s.legal_io_email || "");
|
||||
setLegalWebsiteUrl(s.legal_website_url || "");
|
||||
setLegalEffectiveDate(s.legal_effective_date || "");
|
||||
})
|
||||
.catch((e: any) => setResult({ ok: false, message: e?.message || "Failed to load settings" }))
|
||||
.finally(() => setLoadingInitial(false));
|
||||
}, [token]);
|
||||
|
||||
// Clear result when switching tabs
|
||||
const switchTab = (id: TabId) => { setActiveTab(id); setResult(null); };
|
||||
|
||||
// ── Save helpers ──────────────────────────────────────────────────────────
|
||||
const save = async (updates: Record<string, string>) => {
|
||||
if (!token) return;
|
||||
setSaving(true);
|
||||
setResult(null);
|
||||
try {
|
||||
await apiFetch("/api/settings", { method: "PUT", authToken: token, body: updates });
|
||||
setResult({ ok: true, message: "Saved." });
|
||||
reloadSettings();
|
||||
} catch (e: any) {
|
||||
setResult({ ok: false, message: e?.message || "Failed to save" });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveOrganisation = () => save({
|
||||
org_name: orgName.trim(),
|
||||
org_tagline: orgTagline.trim(),
|
||||
org_email: orgEmail.trim(),
|
||||
org_phone: orgPhone.trim(),
|
||||
org_address: orgAddress.trim(),
|
||||
app_base_url: appBaseUrl.trim(),
|
||||
});
|
||||
|
||||
const saveBranding = async () => {
|
||||
if (!token) return;
|
||||
setSaving(true);
|
||||
setResult(null);
|
||||
try {
|
||||
let finalLogoUrl = logoUrl;
|
||||
if (logoFile) {
|
||||
const fd = new FormData();
|
||||
fd.append("image", logoFile);
|
||||
const res = await fetch(`${API_BASE}/api/uploads/logo`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: fd,
|
||||
});
|
||||
if (!res.ok) throw new Error("Logo upload failed");
|
||||
const data = await res.json();
|
||||
finalLogoUrl = data.url || logoUrl;
|
||||
}
|
||||
await apiFetch("/api/settings", {
|
||||
method: "PUT", authToken: token,
|
||||
body: { accent_color: accentColor, logo_url: finalLogoUrl },
|
||||
});
|
||||
setLogoUrl(finalLogoUrl);
|
||||
setLogoFile(null);
|
||||
setLogoPreview(null);
|
||||
setResult({ ok: true, message: "Saved." });
|
||||
reloadSettings();
|
||||
} catch (e: any) {
|
||||
setResult({ ok: false, message: e?.message || "Failed to save" });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveNotifications = () => save({ reg_notification_emails: notifEmails.trim() });
|
||||
|
||||
const saveSmtp = async () => {
|
||||
if (!token) return;
|
||||
setSaving(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const updates: Record<string, string> = {
|
||||
smtp_host: smtpHost.trim(),
|
||||
smtp_port: smtpPort.trim(),
|
||||
smtp_secure: smtpSecure ? "true" : "false",
|
||||
smtp_from: smtpFrom.trim(),
|
||||
smtp_user: smtpUser.trim(),
|
||||
};
|
||||
if (smtpPass.trim() !== "") updates.smtp_pass = smtpPass.trim();
|
||||
await apiFetch("/api/settings", { method: "PUT", authToken: token, body: updates });
|
||||
if (smtpPass.trim() !== "") { setSmtpPassSet(true); setSmtpPass(""); }
|
||||
setResult({ ok: true, message: "Saved." });
|
||||
reloadSettings();
|
||||
} catch (e: any) {
|
||||
setResult({ ok: false, message: e?.message || "Failed to save" });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveLegal = () => save({
|
||||
legal_operator_name: legalOperatorName.trim(),
|
||||
legal_io_name: legalIoName.trim(),
|
||||
legal_io_email: legalIoEmail.trim(),
|
||||
legal_website_url: legalWebsiteUrl.trim(),
|
||||
legal_effective_date: legalEffectiveDate.trim(),
|
||||
});
|
||||
|
||||
const handleTestSmtp = async () => {
|
||||
if (!token) return;
|
||||
setSmtpTesting(true);
|
||||
setSmtpTestResult(null);
|
||||
try {
|
||||
await apiFetch("/api/settings/test-smtp", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: {
|
||||
host: smtpHost.trim(),
|
||||
port: smtpPort.trim(),
|
||||
secure: smtpSecure,
|
||||
from: smtpFrom.trim(),
|
||||
user: smtpUser.trim(),
|
||||
pass: smtpPass.trim() !== "" ? smtpPass.trim() : "••••••••",
|
||||
},
|
||||
});
|
||||
setSmtpTestResult({ ok: true, message: "Connection successful — check your inbox for a test email." });
|
||||
} catch (e: any) {
|
||||
setSmtpTestResult({ ok: false, message: e?.message || "SMTP test failed", raw: e?.data?.raw });
|
||||
} finally {
|
||||
setSmtpTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loadingInitial) return <div className="p-6 text-sm text-gray-500">Loading settings…</div>;
|
||||
|
||||
const currentLogoSrc = logoPreview || (logoUrl ? resolveToApiOrigin(logoUrl) : null);
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto w-full p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Site Settings</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Configure your organisation, branding, email, and legal pages.</p>
|
||||
</div>
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200"
|
||||
onClick={() => router.push("/dashboard/admin")}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex items-center gap-2 flex-wrap mb-6">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => switchTab(tab.id)}
|
||||
className={`px-3 py-1.5 text-sm rounded border transition-colors ${
|
||||
activeTab === tab.id
|
||||
? "bg-indigo-600 text-white border-indigo-600"
|
||||
: "bg-white text-gray-800 border-gray-200 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Organisation ─────────────────────────────────────────────────── */}
|
||||
{activeTab === "organisation" && (
|
||||
<div className="space-y-4">
|
||||
<Field label="Organisation name" required>
|
||||
<input className={inputCls} placeholder="Hope Family Church"
|
||||
value={orgName} onChange={e => setOrgName(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Tagline">
|
||||
<input className={inputCls} placeholder="Connecting community through events"
|
||||
value={orgTagline} onChange={e => setOrgTagline(e.target.value)} />
|
||||
</Field>
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<Field label="Contact email">
|
||||
<input type="email" className={inputCls} placeholder="admin@yourchurch.org"
|
||||
value={orgEmail} onChange={e => setOrgEmail(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Phone">
|
||||
<input className={inputCls} placeholder="+27 12 345 6789"
|
||||
value={orgPhone} onChange={e => setOrgPhone(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Address">
|
||||
<input className={inputCls} placeholder="123 Church St, City"
|
||||
value={orgAddress} onChange={e => setOrgAddress(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Site URL" hint="The public URL of this site — used in email links (e.g. password reset, ticket delivery). e.g. https://events.yourchurch.org">
|
||||
<input className={inputCls} placeholder="https://events.yourchurch.org"
|
||||
value={appBaseUrl} onChange={e => setAppBaseUrl(e.target.value)} />
|
||||
</Field>
|
||||
<SaveBar saving={saving} onSave={saveOrganisation} result={result} onDismiss={() => setResult(null)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Branding ─────────────────────────────────────────────────────── */}
|
||||
{activeTab === "branding" && (
|
||||
<div className="space-y-5">
|
||||
<Field label="Accent / brand colour">
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="color" className="h-10 w-20 border rounded cursor-pointer"
|
||||
value={accentColor} onChange={e => setAccentColor(e.target.value)} />
|
||||
<input className={`${inputCls} font-mono`} placeholder="#2563eb"
|
||||
value={accentColor} onChange={e => setAccentColor(e.target.value)} />
|
||||
</div>
|
||||
<div className="mt-2 h-6 rounded-lg transition-colors" style={{ background: accentColor }} />
|
||||
</Field>
|
||||
|
||||
<Field label="Site logo" hint="PNG, JPG, SVG or WebP, max 2 MB. Displayed in the navigation bar.">
|
||||
{currentLogoSrc && (
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={currentLogoSrc} alt="Current logo" className="h-16 object-contain border rounded p-1 bg-gray-50" />
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-red-500 hover:text-red-700"
|
||||
onClick={() => { setLogoUrl(""); setLogoFile(null); setLogoPreview(null); if (fileRef.current) fileRef.current.value = ""; }}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<input ref={fileRef} type="file" accept="image/*" onChange={e => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setLogoFile(file);
|
||||
setLogoPreview(URL.createObjectURL(file));
|
||||
}} className="text-sm" />
|
||||
</Field>
|
||||
|
||||
<SaveBar saving={saving} onSave={saveBranding} result={result} onDismiss={() => setResult(null)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Notifications ─────────────────────────────────────────────────── */}
|
||||
{activeTab === "notifications" && (
|
||||
<div className="space-y-4">
|
||||
<Field
|
||||
label="Registration notification emails"
|
||||
hint="Who gets notified when someone registers for an event. Separate multiple addresses with commas."
|
||||
>
|
||||
<input className={inputCls} placeholder="registrations@yourchurch.org, admin@yourchurch.org"
|
||||
value={notifEmails} onChange={e => setNotifEmails(e.target.value)} />
|
||||
</Field>
|
||||
<SaveBar saving={saving} onSave={saveNotifications} result={result} onDismiss={() => setResult(null)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Email / SMTP ──────────────────────────────────────────────────── */}
|
||||
{activeTab === "email" && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
Outgoing email for tickets, payment confirmations, and account notifications.
|
||||
Leave blank to use server environment variables. The password is stored encrypted.
|
||||
</p>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<Field label="SMTP host">
|
||||
<input className={inputCls} placeholder="smtp.yourprovider.com"
|
||||
value={smtpHost} onChange={e => setSmtpHost(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Port">
|
||||
<input type="number" className={inputCls} placeholder="587"
|
||||
value={smtpPort} onChange={e => setSmtpPort(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" id="smtpSecure" checked={smtpSecure}
|
||||
onChange={e => setSmtpSecure(e.target.checked)} className="rounded" />
|
||||
<label htmlFor="smtpSecure" className="text-sm text-gray-700">Use TLS/SSL (port 465)</label>
|
||||
</div>
|
||||
|
||||
<Field label="From address" hint="The address emails appear to come from.">
|
||||
<input type="email" className={inputCls} placeholder="no-reply@yourchurch.org"
|
||||
value={smtpFrom} onChange={e => setSmtpFrom(e.target.value)} />
|
||||
</Field>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<Field label="SMTP username">
|
||||
<input type="email" className={inputCls} placeholder="mail@yourchurch.org"
|
||||
value={smtpUser} onChange={e => setSmtpUser(e.target.value)} autoComplete="username" />
|
||||
</Field>
|
||||
<Field
|
||||
label="SMTP password"
|
||||
hint={smtpPassSet ? "Password is saved. Leave blank to keep it." : undefined}
|
||||
>
|
||||
<div className="relative">
|
||||
<input type="password" className={inputCls}
|
||||
placeholder={smtpPassSet ? "Leave blank to keep current" : "Enter password"}
|
||||
value={smtpPass} onChange={e => setSmtpPass(e.target.value)} autoComplete="new-password" />
|
||||
{smtpPassSet && smtpPass === "" && (
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-green-600 pointer-events-none">● saved</span>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{/* Test connection */}
|
||||
<div className="pt-1 space-y-2">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
disabled={smtpTesting || !smtpHost.trim()}
|
||||
onClick={handleTestSmtp}
|
||||
className="px-4 py-2 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 disabled:opacity-50 border"
|
||||
>
|
||||
{smtpTesting ? "Testing…" : "Test connection"}
|
||||
</button>
|
||||
{smtpTestResult && (
|
||||
<span className={`text-sm ${smtpTestResult.ok ? "text-green-600" : "text-red-600"}`}>
|
||||
{smtpTestResult.ok ? "✓" : "✗"} {smtpTestResult.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{smtpTestResult && !smtpTestResult.ok && smtpTestResult.raw && (
|
||||
<details className="text-xs text-gray-500">
|
||||
<summary className="cursor-pointer select-none hover:text-gray-700">Show technical details</summary>
|
||||
<pre className="mt-1 p-2 bg-gray-100 rounded text-xs overflow-x-auto whitespace-pre-wrap break-all">{smtpTestResult.raw}</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SaveBar saving={saving} onSave={saveSmtp} result={result} onDismiss={() => setResult(null)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Legal ─────────────────────────────────────────────────────────── */}
|
||||
{activeTab === "legal" && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
These values populate the Terms of Use and Privacy Policy pages automatically.
|
||||
</p>
|
||||
|
||||
<Field label="Operator / responsible party"
|
||||
hint='Shown in the "Owned and operated by" line of the Terms of Use.'>
|
||||
<input className={inputCls} placeholder="Jane Smith on behalf of Example Church, City, South Africa"
|
||||
value={legalOperatorName} onChange={e => setLegalOperatorName(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Website URL (without https://)">
|
||||
<input className={inputCls} placeholder="events.yourchurch.org"
|
||||
value={legalWebsiteUrl} onChange={e => setLegalWebsiteUrl(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Effective date">
|
||||
<input className={inputCls} placeholder="April 2026"
|
||||
value={legalEffectiveDate} onChange={e => setLegalEffectiveDate(e.target.value)} />
|
||||
</Field>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<p className="text-sm font-medium text-gray-700 mb-3">Information Officer (POPIA)</p>
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<Field label="Full name">
|
||||
<input className={inputCls} placeholder="Jane Smith"
|
||||
value={legalIoName} onChange={e => setLegalIoName(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Email">
|
||||
<input type="email" className={inputCls} placeholder="io@yourchurch.org"
|
||||
value={legalIoEmail} onChange={e => setLegalIoEmail(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SaveBar saving={saving} onSave={saveLegal} result={result} onDismiss={() => setResult(null)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
interface UserItem {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
phoneNumber?: string | null;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const roleOptions = ["user", "staff", "supervisor", "admin"] as const;
|
||||
type Role = typeof roleOptions[number];
|
||||
|
||||
// Simple fuzzy: tolerate one missing/swapped char by checking if query chars appear in order
|
||||
function fuzzyMatch(query: string, target: string): boolean {
|
||||
const q = query.toLowerCase();
|
||||
const t = target.toLowerCase();
|
||||
if (t.includes(q)) return true;
|
||||
// token-based: all tokens must appear somewhere
|
||||
const tokens = q.split(/\s+/).filter(Boolean);
|
||||
return tokens.every(tok => t.includes(tok));
|
||||
}
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const { user, loading, token } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const isAdmin = useMemo(() => user?.role === "admin", [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!user) router.replace("/login");
|
||||
}, [user, loading, router]);
|
||||
|
||||
// Data state
|
||||
const [users, setUsers] = useState<UserItem[]>([]);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
|
||||
// Search and filter state (server-side)
|
||||
const [query, setQuery] = useState("");
|
||||
const [debouncedQuery, setDebouncedQuery] = useState("");
|
||||
const [roleFilter, setRoleFilter] = useState<Role | "">("");
|
||||
const [activeFilter, setActiveFilter] = useState<"" | "true" | "false">("");
|
||||
|
||||
// Debounce search
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedQuery(query), 350);
|
||||
return () => clearTimeout(t);
|
||||
}, [query]);
|
||||
|
||||
// Create form state
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [cName, setCName] = useState("");
|
||||
const [cEmail, setCEmail] = useState("");
|
||||
const [cPassword, setCPassword] = useState("");
|
||||
const [cPhone, setCPhone] = useState("");
|
||||
const [cRole, setCRole] = useState<Role>("user");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// Inline edit state
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editData, setEditData] = useState<Partial<UserItem> & { password?: string }>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const buildQuery = useCallback((p: number, ps = pageSize) => {
|
||||
const qs = new URLSearchParams({ page: String(p), limit: String(ps) });
|
||||
if (debouncedQuery.trim()) qs.set("search", debouncedQuery.trim());
|
||||
if (roleFilter) qs.set("role", roleFilter);
|
||||
if (activeFilter !== "") qs.set("isActive", activeFilter);
|
||||
return `/api/users?${qs.toString()}`;
|
||||
}, [debouncedQuery, roleFilter, activeFilter, pageSize]);
|
||||
|
||||
const loadUsers = useCallback(async (p = 1, ps = pageSize) => {
|
||||
if (!token) return;
|
||||
setError(null);
|
||||
setFetching(true);
|
||||
try {
|
||||
const url = buildQuery(p, ps);
|
||||
const res = await apiFetch<any>(url, { authToken: token });
|
||||
setUsers(Array.isArray(res?.data) ? res.data : []);
|
||||
setTotal(res?.total ?? 0);
|
||||
setTotalPages(res?.pages ?? 1);
|
||||
setPage(p);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load users");
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
}, [token, buildQuery]);
|
||||
|
||||
// Initial load + reload on filter change
|
||||
useEffect(() => { if (token) loadUsers(1); }, [token, debouncedQuery, roleFilter, activeFilter]);
|
||||
|
||||
const goToPage = (p: number) => loadUsers(p);
|
||||
|
||||
const handlePageSizeChange = (newSize: number) => {
|
||||
setPageSize(newSize);
|
||||
loadUsers(1, newSize);
|
||||
};
|
||||
|
||||
const resetCreateForm = () => {
|
||||
setCName(""); setCEmail(""); setCPassword(""); setCPhone(""); setCRole("user");
|
||||
};
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!cEmail || !cPassword) { setError("Email and password are required"); return; }
|
||||
try {
|
||||
setCreating(true);
|
||||
const created = await apiFetch<any>("/api/users", {
|
||||
method: "POST",
|
||||
body: { name: cName || cEmail.split("@")[0], email: cEmail, password: cPassword, phoneNumber: cPhone || undefined },
|
||||
});
|
||||
if (cRole && cRole !== "user" && created?.id) {
|
||||
await apiFetch(`/api/users/${encodeURIComponent(created.id)}`, {
|
||||
method: "PUT", authToken: token!, body: { role: cRole },
|
||||
});
|
||||
}
|
||||
resetCreateForm();
|
||||
setCreateOpen(false);
|
||||
await loadUsers(page);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to create user");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = (u: UserItem) => { setEditingId(u.id); setEditData({ ...u, password: "" }); };
|
||||
const cancelEdit = () => { setEditingId(null); setEditData({}); };
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!editingId) return;
|
||||
try {
|
||||
setSaving(true);
|
||||
const payload: any = {
|
||||
name: editData.name,
|
||||
email: editData.email,
|
||||
role: editData.role,
|
||||
phoneNumber: editData.phoneNumber || null,
|
||||
isActive: editData.isActive,
|
||||
};
|
||||
if (editData.password && editData.password.trim().length > 0) {
|
||||
payload.password = editData.password.trim();
|
||||
}
|
||||
await apiFetch(`/api/users/${encodeURIComponent(editingId)}`, {
|
||||
method: "PUT", authToken: token!, body: payload,
|
||||
});
|
||||
setEditingId(null);
|
||||
setEditData({});
|
||||
await loadUsers(page);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to save user");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const revokeUserSessions = async (id: string, name: string) => {
|
||||
if (!confirm(`Revoke all active sessions for ${name}? They will be signed out on all devices.`)) return;
|
||||
try {
|
||||
await apiFetch(`/api/users/${encodeURIComponent(id)}/revoke-sessions`, { method: "POST", authToken: token! });
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to revoke sessions");
|
||||
}
|
||||
};
|
||||
|
||||
const deactivate = async (id: string) => {
|
||||
if (!confirm("Deactivate this user? Their account will be disabled.")) return;
|
||||
try {
|
||||
await apiFetch(`/api/users/${encodeURIComponent(id)}`, { method: "DELETE", authToken: token! });
|
||||
await loadUsers(page);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to deactivate user");
|
||||
}
|
||||
};
|
||||
|
||||
const deleteUserData = async (id: string, name: string) => {
|
||||
if (!confirm(`Delete personal data for "${name}"?\n\nThis will set their name to "Deleted User", clear their email and phone number, and deactivate the account. This action cannot be undone.`)) return;
|
||||
try {
|
||||
await apiFetch(`/api/users/${encodeURIComponent(id)}/anonymize`, { method: "POST", authToken: token! });
|
||||
await loadUsers(page);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to delete user data");
|
||||
}
|
||||
};
|
||||
|
||||
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">User Management</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>
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={() => setCreateOpen(v => !v)}>
|
||||
{createOpen ? "Close" : "Create user"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isAdmin && (
|
||||
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
|
||||
You need admin access to manage users.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
|
||||
{createOpen && (
|
||||
<form onSubmit={handleCreate} className="border rounded-xl p-4 bg-white shadow-sm mb-6 grid sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Name</label>
|
||||
<input className="w-full border rounded px-3 py-2" value={cName} onChange={e => setCName(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Email</label>
|
||||
<input type="email" className="w-full border rounded px-3 py-2" value={cEmail} onChange={e => setCEmail(e.target.value)} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Password</label>
|
||||
<input type="password" className="w-full border rounded px-3 py-2" value={cPassword} onChange={e => setCPassword(e.target.value)} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Phone</label>
|
||||
<input className="w-full border rounded px-3 py-2" value={cPhone} onChange={e => setCPhone(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Role</label>
|
||||
<select className="w-full border rounded px-3 py-2" value={cRole} onChange={e => setCRole(e.target.value as Role)}>
|
||||
{roleOptions.map(r => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button disabled={creating} className="px-3 py-2 rounded bg-indigo-600 text-white disabled:opacity-50" type="submit">
|
||||
{creating ? "Creating…" : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
{/* Filters row */}
|
||||
<div className="flex flex-wrap items-end gap-3 mb-4">
|
||||
<div className="flex-1 min-w-48">
|
||||
<label className="block text-xs text-gray-600 mb-1">Search (name, email, phone)</label>
|
||||
<input
|
||||
className="w-full border rounded px-3 py-1.5 text-sm"
|
||||
placeholder="Type to search…"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Role</label>
|
||||
<select className="border rounded px-2 py-1.5 text-sm" value={roleFilter} onChange={e => setRoleFilter(e.target.value as Role | "")}>
|
||||
<option value="">All roles</option>
|
||||
{roleOptions.map(r => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Status</label>
|
||||
<select className="border rounded px-2 py-1.5 text-sm" value={activeFilter} onChange={e => setActiveFilter(e.target.value as "" | "true" | "false")}>
|
||||
<option value="">Active & inactive</option>
|
||||
<option value="true">Active only</option>
|
||||
<option value="false">Inactive only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Per page</label>
|
||||
<select className="border rounded px-2 py-1.5 text-sm" value={pageSize} onChange={e => handlePageSizeChange(Number(e.target.value))}>
|
||||
<option value={10}>10</option>
|
||||
<option value={25}>25</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
</div>
|
||||
<button className="text-sm px-2 py-1.5 rounded bg-gray-100 hover:bg-gray-200" onClick={() => loadUsers(page)} disabled={fetching}>
|
||||
{fetching ? "Refreshing…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500 mb-2">
|
||||
{total} user{total !== 1 ? "s" : ""} total
|
||||
{total > 0 && ` — page ${page} of ${totalPages}`}
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-600 border-b">
|
||||
<th className="p-2">Name</th>
|
||||
<th className="p-2">Email</th>
|
||||
<th className="p-2">Role</th>
|
||||
<th className="p-2">Phone</th>
|
||||
<th className="p-2">Active</th>
|
||||
<th className="p-2">Password</th>
|
||||
<th className="p-2">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(u => (
|
||||
<tr key={u.id} className="border-t hover:bg-gray-50">
|
||||
<td className="p-2">
|
||||
{editingId === u.id ? (
|
||||
<input className="border rounded px-2 py-1 w-44" value={editData.name || ""} onChange={e => setEditData(d => ({ ...d, name: e.target.value }))} />
|
||||
) : (
|
||||
<span className={`font-medium ${!u.isActive ? "text-gray-400" : ""}`}>{u.name}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{editingId === u.id ? (
|
||||
<input className="border rounded px-2 py-1 w-60" value={editData.email || ""} onChange={e => setEditData(d => ({ ...d, email: e.target.value }))} />
|
||||
) : (
|
||||
<span className={u.email?.endsWith("@deleted.local") ? "text-gray-400 italic" : ""}>{u.email}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{editingId === u.id ? (
|
||||
<select className="border rounded px-2 py-1" value={(editData.role as Role) || (u.role as Role)} onChange={e => setEditData(d => ({ ...d, role: e.target.value }))}>
|
||||
{roleOptions.map(r => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<span className="capitalize">{u.role}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{editingId === u.id ? (
|
||||
<input className="border rounded px-2 py-1 w-36" value={editData.phoneNumber || ""} onChange={e => setEditData(d => ({ ...d, phoneNumber: e.target.value }))} />
|
||||
) : (
|
||||
<span>{u.phoneNumber || ""}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{editingId === u.id ? (
|
||||
<input type="checkbox" checked={!!editData.isActive} onChange={e => setEditData(d => ({ ...d, isActive: e.target.checked }))} />
|
||||
) : (
|
||||
<span className={u.isActive ? "text-green-700" : "text-gray-400"}>{u.isActive ? "Yes" : "No"}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{editingId === u.id ? (
|
||||
<input type="password" placeholder="Set new password" className="border rounded px-2 py-1 w-44" value={editData.password || ""} onChange={e => setEditData(d => ({ ...d, password: e.target.value }))} />
|
||||
) : (
|
||||
<span className="text-gray-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{editingId === u.id ? (
|
||||
<div className="flex gap-2">
|
||||
<button className="px-2 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200" onClick={cancelEdit} disabled={saving}>Cancel</button>
|
||||
<button className="px-2 py-1 text-xs rounded bg-blue-600 text-white disabled:opacity-50" onClick={saveEdit} disabled={saving}>{saving ? "Saving…" : "Save"}</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
<button className="px-2 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200" onClick={() => startEdit(u)}>Edit</button>
|
||||
<button className="px-2 py-1 text-xs rounded bg-amber-500 text-white hover:bg-amber-600" onClick={() => revokeUserSessions(u.id, u.name)} title="Sign out all devices">Sessions</button>
|
||||
<button className="px-2 py-1 text-xs rounded bg-orange-500 text-white hover:bg-orange-600" onClick={() => deactivate(u.id)} title="Deactivate account">Deactivate</button>
|
||||
<button className="px-2 py-1 text-xs rounded bg-red-700 text-white hover:bg-red-800" onClick={() => deleteUserData(u.id, u.name)} title="Erase personal data">Delete data</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{users.length === 0 && !fetching && (
|
||||
<tr>
|
||||
<td className="p-3 text-gray-500" colSpan={7}>No users found.</td>
|
||||
</tr>
|
||||
)}
|
||||
{fetching && (
|
||||
<tr>
|
||||
<td className="p-3 text-gray-400" colSpan={7}>Loading…</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4 text-sm">
|
||||
<span className="text-gray-500">Page {page} of {totalPages}</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40"
|
||||
disabled={page <= 1 || fetching}
|
||||
onClick={() => goToPage(page - 1)}
|
||||
>
|
||||
← Prev
|
||||
</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
.filter(p => p === 1 || p === totalPages || Math.abs(p - page) <= 1)
|
||||
.reduce<(number | "…")[]>((acc, p, i, arr) => {
|
||||
if (i > 0 && (p as number) - (arr[i - 1] as number) > 1) acc.push("…");
|
||||
acc.push(p);
|
||||
return acc;
|
||||
}, [])
|
||||
.map((p, i) =>
|
||||
p === "…" ? (
|
||||
<span key={`ellipsis-${i}`} className="px-2 py-1 text-gray-400">…</span>
|
||||
) : (
|
||||
<button
|
||||
key={p}
|
||||
className={`px-2 py-1 rounded ${page === p ? "bg-indigo-600 text-white" : "bg-gray-100 hover:bg-gray-200"}`}
|
||||
disabled={fetching}
|
||||
onClick={() => goToPage(p as number)}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<button
|
||||
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40"
|
||||
disabled={page >= totalPages || fetching}
|
||||
onClick={() => goToPage(page + 1)}
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,830 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type WAStatus =
|
||||
| "WORKING"
|
||||
| "CONNECTED"
|
||||
| "SCAN_QR_CODE"
|
||||
| "STARTING"
|
||||
| "FAILED"
|
||||
| "STOPPED"
|
||||
| string;
|
||||
|
||||
interface ConfigResponse {
|
||||
tokenMasked: string;
|
||||
instanceId: string;
|
||||
hasToken: boolean;
|
||||
hasInstance: boolean;
|
||||
configured: boolean;
|
||||
}
|
||||
|
||||
interface StatusResponse {
|
||||
status: WAStatus;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
WORKING: "bg-green-100 text-green-800 border-green-300",
|
||||
CONNECTED: "bg-green-100 text-green-800 border-green-300",
|
||||
SCAN_QR_CODE: "bg-yellow-100 text-yellow-800 border-yellow-300",
|
||||
STARTING: "bg-blue-100 text-blue-800 border-blue-300",
|
||||
FAILED: "bg-red-100 text-red-800 border-red-300",
|
||||
STOPPED: "bg-gray-100 text-gray-700 border-gray-300",
|
||||
};
|
||||
|
||||
const STATUS_ICONS: Record<string, string> = {
|
||||
WORKING: "🟢",
|
||||
CONNECTED: "🟢",
|
||||
SCAN_QR_CODE: "📷",
|
||||
STARTING: "🔄",
|
||||
FAILED: "🔴",
|
||||
STOPPED: "⚫",
|
||||
};
|
||||
|
||||
const ACTIVE_STATUSES = new Set(["WORKING", "CONNECTED"]);
|
||||
const POLLING_STATUSES = new Set(["STARTING", "SCAN_QR_CODE", "FAILED", "STOPPED"]);
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
<svg
|
||||
className="animate-spin h-4 w-4 text-indigo-600"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Alert({
|
||||
type,
|
||||
children,
|
||||
}: {
|
||||
type: "ok" | "err" | "info";
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const cls =
|
||||
type === "ok"
|
||||
? "bg-green-50 text-green-800 border-green-200"
|
||||
: type === "err"
|
||||
? "bg-red-50 text-red-800 border-red-200"
|
||||
: "bg-blue-50 text-blue-800 border-blue-200";
|
||||
return (
|
||||
<div className={`p-3 rounded-lg text-sm border ${cls}`}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function WhatsAppAdminPage() {
|
||||
const { user, loading, token } = useAuth();
|
||||
const router = useRouter();
|
||||
const isAdmin = useMemo(() => user?.role === "admin", [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!user || !isAdmin) router.replace("/dashboard");
|
||||
}, [user, loading, isAdmin, router]);
|
||||
|
||||
// ── Config state (drives wizard steps) ──────────────────────────────────────
|
||||
const [cfg, setCfg] = useState<ConfigResponse | null>(null);
|
||||
const [cfgLoading, setCfgLoading] = useState(true);
|
||||
|
||||
// Derived wizard step: 1 = no token, 2 = token but no instance, 3 = fully configured
|
||||
const step = !cfg ? 0 : !cfg.hasToken ? 1 : !cfg.hasInstance ? 2 : 3;
|
||||
|
||||
// ── Step 1 inputs ────────────────────────────────────────────────────────────
|
||||
const [inputToken, setInputToken] = useState("");
|
||||
const [savingToken, setSavingToken] = useState(false);
|
||||
|
||||
// ── Step 2 inputs ────────────────────────────────────────────────────────────
|
||||
const [instanceMode, setInstanceMode] = useState<"enter" | "create">("create");
|
||||
const [inputInstanceId, setInputInstanceId] = useState("");
|
||||
const [savingInstance, setSavingInstance] = useState(false);
|
||||
|
||||
// ── Step 3: session state ────────────────────────────────────────────────────
|
||||
const [status, setStatus] = useState<WAStatus | null>(null);
|
||||
const [statusMsg, setStatusMsg] = useState<string | null>(null);
|
||||
const [qrSrc, setQrSrc] = useState<string | null>(null);
|
||||
const [pairingPhone, setPairingPhone] = useState("");
|
||||
|
||||
// ── Shared action feedback ───────────────────────────────────────────────────
|
||||
const [actionMsg, setActionMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
// ── Load config ──────────────────────────────────────────────────────────────
|
||||
const fetchConfig = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const res = await apiFetch<ConfigResponse>("/api/whatsapp/config", { authToken: token });
|
||||
setCfg(res);
|
||||
} catch {
|
||||
// network error — leave cfg null, user sees loading state
|
||||
} finally {
|
||||
setCfgLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => { fetchConfig(); }, [fetchConfig]);
|
||||
|
||||
// ── Status fetch (step 3 only) ───────────────────────────────────────────────
|
||||
const fetchStatus = useCallback(async () => {
|
||||
if (!token || step !== 3) return;
|
||||
try {
|
||||
const res = await apiFetch<StatusResponse>("/api/whatsapp/status", { authToken: token });
|
||||
setStatus(res.status ?? null);
|
||||
setStatusMsg(res.message ?? null);
|
||||
} catch (e: any) {
|
||||
// Re-fetch config — if the session was not found, backend clears the
|
||||
// instance ID and the step recomputes to 2 (Session Instance setup).
|
||||
await fetchConfig();
|
||||
setStatus("FAILED");
|
||||
setStatusMsg(null);
|
||||
}
|
||||
}, [token, step, fetchConfig]);
|
||||
|
||||
useEffect(() => { if (step === 3) fetchStatus(); }, [step, fetchStatus]);
|
||||
|
||||
// Auto-poll status when not stable
|
||||
useEffect(() => {
|
||||
if (step !== 3 || status === null) return;
|
||||
if (ACTIVE_STATUSES.has(status)) return;
|
||||
const id = setInterval(fetchStatus, 5_000);
|
||||
return () => clearInterval(id);
|
||||
}, [step, status, fetchStatus]);
|
||||
|
||||
// ── QR fetch ─────────────────────────────────────────────────────────────────
|
||||
const fetchQr = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const res = await apiFetch<{ qr?: string }>("/api/whatsapp/qr", { authToken: token });
|
||||
if (res.qr) setQrSrc(`data:image/png;base64,${res.qr}`);
|
||||
} catch {
|
||||
setQrSrc(null);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "SCAN_QR_CODE") { fetchQr(); }
|
||||
else { setQrSrc(null); }
|
||||
}, [status, fetchQr]);
|
||||
|
||||
// Auto-refresh QR every 20s while waiting
|
||||
useEffect(() => {
|
||||
if (status !== "SCAN_QR_CODE") return;
|
||||
const id = setInterval(fetchQr, 20_000);
|
||||
return () => clearInterval(id);
|
||||
}, [status, fetchQr]);
|
||||
|
||||
// ── Generic session action ───────────────────────────────────────────────────
|
||||
const doAction = async (action: string, body?: object) => {
|
||||
if (!token) return;
|
||||
setBusy(action);
|
||||
setActionMsg(null);
|
||||
try {
|
||||
const res = await apiFetch<any>(`/api/whatsapp/${action}`, {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body,
|
||||
});
|
||||
setActionMsg({ type: "ok", text: res?.message || `${action} successful.` });
|
||||
await fetchStatus();
|
||||
await fetchConfig();
|
||||
} catch (e: any) {
|
||||
let msg = e?.message || `${action} failed.`;
|
||||
try { msg = JSON.parse(msg)?.message || msg; } catch {}
|
||||
// SESSION_NOT_FOUND: backend cleared the instance ID — re-fetch config so
|
||||
// the wizard steps back to Step 2; no need to show an error message.
|
||||
await fetchConfig();
|
||||
if (!msg.includes("SESSION_NOT_FOUND")) {
|
||||
setActionMsg({ type: "err", text: msg });
|
||||
}
|
||||
await fetchStatus();
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Step 1: Save token ──────────────────────────────────────────────────────
|
||||
const saveToken = async () => {
|
||||
if (!inputToken.trim()) {
|
||||
setActionMsg({ type: "err", text: "Please enter your WAWP access token." });
|
||||
return;
|
||||
}
|
||||
setSavingToken(true);
|
||||
setActionMsg(null);
|
||||
try {
|
||||
await apiFetch("/api/whatsapp/config", {
|
||||
method: "POST",
|
||||
authToken: token!,
|
||||
body: { token: inputToken.trim(), instanceId: "" },
|
||||
});
|
||||
setInputToken("");
|
||||
await fetchConfig();
|
||||
} catch (e: any) {
|
||||
setActionMsg({ type: "err", text: e?.message || "Failed to save token." });
|
||||
} finally {
|
||||
setSavingToken(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Step 2: Enter existing instance ID ──────────────────────────────────────
|
||||
const saveInstanceId = async () => {
|
||||
if (!inputInstanceId.trim()) {
|
||||
setActionMsg({ type: "err", text: "Please enter the Instance ID." });
|
||||
return;
|
||||
}
|
||||
setSavingInstance(true);
|
||||
setActionMsg(null);
|
||||
try {
|
||||
await apiFetch("/api/whatsapp/config", {
|
||||
method: "POST",
|
||||
authToken: token!,
|
||||
body: { token: "", instanceId: inputInstanceId.trim() },
|
||||
// token left blank → backend keeps existing token
|
||||
});
|
||||
setInputInstanceId("");
|
||||
await fetchConfig();
|
||||
} catch (e: any) {
|
||||
setActionMsg({ type: "err", text: e?.message || "Failed to save Instance ID." });
|
||||
} finally {
|
||||
setSavingInstance(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Step 2: Create new instance ─────────────────────────────────────────────
|
||||
const createInstance = async () => {
|
||||
setSavingInstance(true);
|
||||
setActionMsg(null);
|
||||
try {
|
||||
const res = await apiFetch<any>("/api/whatsapp/create-instance", {
|
||||
method: "POST",
|
||||
authToken: token!,
|
||||
});
|
||||
setActionMsg({ type: "ok", text: res?.message || "Instance created." });
|
||||
await fetchConfig();
|
||||
} catch (e: any) {
|
||||
setActionMsg({ type: "err", text: e?.message || "Failed to create instance." });
|
||||
} finally {
|
||||
setSavingInstance(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Pairing code ────────────────────────────────────────────────────────────
|
||||
const requestPairingCode = async () => {
|
||||
if (!pairingPhone.trim()) {
|
||||
setActionMsg({ type: "err", text: "Enter your phone number first." });
|
||||
return;
|
||||
}
|
||||
await doAction("request-code", { phoneNumber: pairingPhone.trim() });
|
||||
};
|
||||
|
||||
// ─── Reset credentials (go back to step 1) ───────────────────────────────────
|
||||
const resetToken = async () => {
|
||||
if (!confirm("This will clear your saved access token. You will need to re-enter it. Continue?")) return;
|
||||
try {
|
||||
await apiFetch("/api/whatsapp/config", {
|
||||
method: "POST",
|
||||
authToken: token!,
|
||||
body: { token: "_clear_", instanceId: "" },
|
||||
});
|
||||
} catch {}
|
||||
// Force a re-read — even if the above fails, clear local state
|
||||
setCfg(prev => prev ? { ...prev, hasToken: false, hasInstance: false, configured: false, tokenMasked: "", instanceId: "" } : null);
|
||||
};
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Render
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
if (loading || cfgLoading) {
|
||||
return (
|
||||
<div className="max-w-xl mx-auto w-full p-6 flex items-center gap-2 text-sm text-gray-500">
|
||||
<Spinner /> Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-xl mx-auto w-full p-6 space-y-6">
|
||||
{/* Back button */}
|
||||
<button
|
||||
onClick={() => router.push("/dashboard")}
|
||||
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-800 transition-colors"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Back
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-3xl">💬</span>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold leading-tight">WhatsApp Integration</h1>
|
||||
<p className="text-sm text-gray-500">Powered by WAWP</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step indicator */}
|
||||
<StepIndicator step={step} />
|
||||
|
||||
{/* Global action message */}
|
||||
{actionMsg && (
|
||||
<Alert type={actionMsg.type}>{actionMsg.text}</Alert>
|
||||
)}
|
||||
|
||||
{/* ── STEP 1: Enter access token ──────────────────────────────────────── */}
|
||||
{step === 1 && (
|
||||
<section className="border rounded-xl p-6 bg-white shadow-sm space-y-4">
|
||||
<h2 className="text-lg font-semibold">Step 1 — Enter your WAWP Access Token</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
Your access token is found in your WAWP account dashboard at{" "}
|
||||
<a
|
||||
href="https://app.wawp.net"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-indigo-600 hover:underline"
|
||||
>
|
||||
app.wawp.net
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-medium text-gray-700">Access Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={inputToken}
|
||||
onChange={e => setInputToken(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && saveToken()}
|
||||
placeholder="Paste your WAWP access token"
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={saveToken}
|
||||
disabled={savingToken}
|
||||
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{savingToken && <Spinner />}
|
||||
{savingToken ? "Saving…" : "Save Token & Continue"}
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── STEP 2: Instance ID ─────────────────────────────────────────────── */}
|
||||
{step === 2 && (
|
||||
<section className="border rounded-xl p-6 bg-white shadow-sm space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Step 2 — Set Up Session Instance</h2>
|
||||
<span className="text-xs text-gray-400 font-mono bg-gray-100 px-2 py-0.5 rounded">
|
||||
Token: {cfg?.tokenMasked}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
You need a WAWP session instance. Either create a brand-new one, or enter an
|
||||
existing Instance ID.
|
||||
</p>
|
||||
|
||||
{/* Tab toggle */}
|
||||
<div className="flex rounded-lg border overflow-hidden text-sm font-medium">
|
||||
<button
|
||||
onClick={() => setInstanceMode("create")}
|
||||
className={`flex-1 px-4 py-2.5 transition-colors ${
|
||||
instanceMode === "create"
|
||||
? "bg-indigo-600 text-white"
|
||||
: "bg-white text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
Create new instance
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setInstanceMode("enter")}
|
||||
className={`flex-1 px-4 py-2.5 border-l transition-colors ${
|
||||
instanceMode === "enter"
|
||||
? "bg-indigo-600 text-white"
|
||||
: "bg-white text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
Enter existing ID
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{instanceMode === "create" && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-gray-600">
|
||||
Click below to create a new WAWP session. The Instance ID will be saved
|
||||
automatically.
|
||||
</p>
|
||||
<button
|
||||
onClick={createInstance}
|
||||
disabled={savingInstance}
|
||||
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{savingInstance && <Spinner />}
|
||||
{savingInstance ? "Creating…" : "Create Instance"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{instanceMode === "enter" && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Instance ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={inputInstanceId}
|
||||
onChange={e => setInputInstanceId(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && saveInstanceId()}
|
||||
placeholder="e.g. BF14B761C364"
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={saveInstanceId}
|
||||
disabled={savingInstance}
|
||||
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{savingInstance && <Spinner />}
|
||||
{savingInstance ? "Saving…" : "Save & Continue"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={resetToken}
|
||||
className="text-xs text-gray-400 hover:text-red-500 hover:underline"
|
||||
>
|
||||
← Change access token
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── STEP 3: Full management ─────────────────────────────────────────── */}
|
||||
{step === 3 && (
|
||||
<>
|
||||
{/* Status card */}
|
||||
<section className="border rounded-xl p-5 bg-white shadow-sm space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Session Status</h2>
|
||||
<button
|
||||
onClick={fetchStatus}
|
||||
className="text-xs text-indigo-600 hover:underline"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{status === null ? (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Spinner /> Fetching status…
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{STATUS_ICONS[status] ?? "⚪"}</span>
|
||||
<span
|
||||
className={`inline-flex items-center px-3 py-1 rounded-full border text-sm font-semibold ${
|
||||
STATUS_COLORS[status] ?? "bg-gray-100 text-gray-700 border-gray-300"
|
||||
}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{statusMsg && <p className="text-xs text-gray-500">{statusMsg}</p>}
|
||||
|
||||
{status === "FAILED" && (
|
||||
<Alert type="err">
|
||||
The session has failed. The system will attempt to auto-restart. You can also
|
||||
restart manually below.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{status && POLLING_STATUSES.has(status) && (
|
||||
<p className="text-xs text-gray-400 flex items-center gap-1">
|
||||
<Spinner /> Auto-refreshing every 5 seconds…
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Config info strip */}
|
||||
<div className="flex flex-wrap gap-3 pt-2 border-t text-xs text-gray-500">
|
||||
<span>
|
||||
Token: <span className="font-mono">{cfg?.tokenMasked || "—"}</span>
|
||||
</span>
|
||||
<span>
|
||||
Instance: <span className="font-mono">{cfg?.instanceId || "—"}</span>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* QR Code */}
|
||||
{status === "SCAN_QR_CODE" && (
|
||||
<section className="border rounded-xl p-5 bg-white shadow-sm space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Scan QR Code</h2>
|
||||
<button
|
||||
onClick={fetchQr}
|
||||
className="text-xs text-indigo-600 hover:underline"
|
||||
>
|
||||
Refresh QR
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
Open WhatsApp → Linked Devices → Link a Device, then scan the code below.
|
||||
</p>
|
||||
{qrSrc ? (
|
||||
<img
|
||||
src={qrSrc}
|
||||
alt="WhatsApp QR Code"
|
||||
className="w-56 h-56 border rounded-lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<Spinner /> Loading QR…
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-gray-400">
|
||||
QR codes expire after ~20 seconds — click Refresh QR if it stops working.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Pairing code */}
|
||||
{status === "SCAN_QR_CODE" && (
|
||||
<section className="border rounded-xl p-5 bg-white shadow-sm space-y-4">
|
||||
<h2 className="text-lg font-semibold">Link by Phone Number Instead</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
Enter your WhatsApp number (SA format, e.g. 082 123 4567) to receive a pairing
|
||||
code on your phone.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="tel"
|
||||
value={pairingPhone}
|
||||
onChange={e => setPairingPhone(e.target.value)}
|
||||
placeholder="082 123 4567"
|
||||
className="flex-1 border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
<button
|
||||
onClick={requestPairingCode}
|
||||
disabled={busy === "request-code"}
|
||||
className="flex items-center gap-1.5 px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{busy === "request-code" && <Spinner />}
|
||||
{busy === "request-code" ? "Sending…" : "Send code"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Session controls */}
|
||||
<section className="border rounded-xl p-5 bg-white shadow-sm space-y-4">
|
||||
<h2 className="text-lg font-semibold">Session Controls</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<ActionButton
|
||||
label="Start"
|
||||
busyLabel="Starting…"
|
||||
isBusy={busy === "start"}
|
||||
disabled={!!busy}
|
||||
color="green"
|
||||
onClick={() => doAction("start")}
|
||||
/>
|
||||
<ActionButton
|
||||
label="Restart"
|
||||
busyLabel="Restarting…"
|
||||
isBusy={busy === "restart"}
|
||||
disabled={!!busy}
|
||||
color="amber"
|
||||
onClick={() => doAction("restart")}
|
||||
/>
|
||||
<ActionButton
|
||||
label="Logout"
|
||||
busyLabel="Logging out…"
|
||||
isBusy={busy === "logout"}
|
||||
disabled={!!busy}
|
||||
color="red-outline"
|
||||
onClick={() => {
|
||||
if (!confirm("This will log out the linked WhatsApp account. Are you sure?")) return;
|
||||
doAction("logout");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Instance management */}
|
||||
<section className="border rounded-xl p-5 bg-white shadow-sm space-y-4">
|
||||
<h2 className="text-lg font-semibold">Instance Management</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
Create a brand-new instance or permanently delete the current one. Deleting will
|
||||
require you to set up a new instance.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<ActionButton
|
||||
label="Create New Instance"
|
||||
busyLabel="Creating…"
|
||||
isBusy={busy === "create-instance"}
|
||||
disabled={!!busy}
|
||||
color="blue"
|
||||
onClick={() => doAction("create-instance")}
|
||||
/>
|
||||
<ActionButton
|
||||
label="Delete Instance"
|
||||
busyLabel="Deleting…"
|
||||
isBusy={busy === "delete-instance"}
|
||||
disabled={!!busy}
|
||||
color="red-outline"
|
||||
onClick={() => {
|
||||
if (!confirm("This will PERMANENTLY delete the instance. You'll need to create a new one. Are you sure?")) return;
|
||||
doAction("delete-instance");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Update credentials */}
|
||||
<details className="border rounded-xl bg-white shadow-sm">
|
||||
<summary className="p-5 cursor-pointer text-sm font-semibold text-gray-700 select-none list-none flex items-center justify-between">
|
||||
<span>Update Credentials</span>
|
||||
<span className="text-gray-400 text-xs">expand ▾</span>
|
||||
</summary>
|
||||
<div className="px-5 pb-5 space-y-3 border-t pt-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
Change your WAWP access token or Instance ID. Leave a field blank to keep the
|
||||
current value.
|
||||
</p>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
New Access Token
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={inputToken}
|
||||
onChange={e => setInputToken(e.target.value)}
|
||||
placeholder="Leave blank to keep current token"
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
New Instance ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={inputInstanceId}
|
||||
onChange={e => setInputInstanceId(e.target.value)}
|
||||
placeholder="Leave blank to keep current instance"
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!inputToken.trim() && !inputInstanceId.trim()) {
|
||||
setActionMsg({ type: "err", text: "Enter at least one field to update." });
|
||||
return;
|
||||
}
|
||||
setSavingToken(true);
|
||||
setActionMsg(null);
|
||||
try {
|
||||
await apiFetch("/api/whatsapp/config", {
|
||||
method: "POST",
|
||||
authToken: token!,
|
||||
body: {
|
||||
token: inputToken.trim() || undefined,
|
||||
instanceId: inputInstanceId.trim() || undefined,
|
||||
},
|
||||
});
|
||||
setActionMsg({ type: "ok", text: "Credentials updated." });
|
||||
setInputToken("");
|
||||
setInputInstanceId("");
|
||||
await fetchConfig();
|
||||
} catch (e: any) {
|
||||
setActionMsg({ type: "err", text: e?.message || "Failed to update." });
|
||||
} finally {
|
||||
setSavingToken(false);
|
||||
}
|
||||
}}
|
||||
disabled={savingToken}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{savingToken && <Spinner />}
|
||||
{savingToken ? "Saving…" : "Save Changes"}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-400 text-center">
|
||||
WhatsApp notifications powered by{" "}
|
||||
<a
|
||||
href="https://wawp.net"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline"
|
||||
>
|
||||
WAWP
|
||||
</a>
|
||||
. Session auto-recovers on failure; admin alert sent if recovery fails.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
function StepIndicator({ step }: { step: number }) {
|
||||
const steps = [
|
||||
{ n: 1, label: "Access Token" },
|
||||
{ n: 2, label: "Session Instance" },
|
||||
{ n: 3, label: "Connected" },
|
||||
];
|
||||
return (
|
||||
<div className="flex items-center gap-0">
|
||||
{steps.map((s, i) => {
|
||||
const done = step > s.n;
|
||||
const current = step === s.n;
|
||||
return (
|
||||
<React.Fragment key={s.n}>
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold border-2 transition-colors ${
|
||||
done
|
||||
? "bg-green-500 border-green-500 text-white"
|
||||
: current
|
||||
? "bg-indigo-600 border-indigo-600 text-white"
|
||||
: "bg-white border-gray-300 text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{done ? "✓" : s.n}
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs mt-1 font-medium ${
|
||||
done || current ? "text-gray-700" : "text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
{i < steps.length - 1 && (
|
||||
<div
|
||||
className={`flex-1 h-0.5 mb-5 mx-1 transition-colors ${
|
||||
done ? "bg-green-400" : "bg-gray-200"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ButtonColor = "green" | "amber" | "blue" | "red-outline";
|
||||
|
||||
function ActionButton({
|
||||
label,
|
||||
busyLabel,
|
||||
isBusy,
|
||||
disabled,
|
||||
color,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
busyLabel: string;
|
||||
isBusy: boolean;
|
||||
disabled: boolean;
|
||||
color: ButtonColor;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const base = "flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50 transition-colors";
|
||||
const colors: Record<ButtonColor, string> = {
|
||||
green: "bg-green-600 text-white hover:bg-green-700",
|
||||
amber: "bg-amber-500 text-white hover:bg-amber-600",
|
||||
blue: "bg-blue-600 text-white hover:bg-blue-700",
|
||||
"red-outline": "border border-red-600 text-red-600 hover:bg-red-50",
|
||||
};
|
||||
return (
|
||||
<button onClick={onClick} disabled={disabled} className={`${base} ${colors[color]}`}>
|
||||
{isBusy && <Spinner />}
|
||||
{isBusy ? busyLabel : label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { Sidebar, MobileSidebar } from "@/components/layout/Sidebar";
|
||||
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
// Redirect unauthenticated users to login
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!user) {
|
||||
router.replace("/login");
|
||||
}
|
||||
}, [user, loading, router]);
|
||||
|
||||
// Role-based route guard within /dashboard
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!user) return; // handled above
|
||||
if (!pathname || !pathname.startsWith("/dashboard")) return;
|
||||
|
||||
const role = user.role || "user";
|
||||
|
||||
// Determine allowed prefixes and default destination per role
|
||||
let allowed: string[] = ["/dashboard/user"]; // everyone can access user dashboard
|
||||
let dest = "/dashboard/user";
|
||||
|
||||
if (role === "admin") {
|
||||
allowed = ["/dashboard/admin", "/dashboard/user"];
|
||||
dest = "/dashboard/admin";
|
||||
} else if (role === "supervisor") {
|
||||
allowed = ["/dashboard/supervisor", "/dashboard/user"];
|
||||
dest = "/dashboard/supervisor";
|
||||
} else if (role === "staff") {
|
||||
allowed = ["/dashboard/staff", "/dashboard/user"];
|
||||
dest = "/dashboard/staff";
|
||||
}
|
||||
|
||||
// Allow admin to access supervisor and staff subpages (but not their root dashboards)
|
||||
const isAllowed =
|
||||
allowed.some(prefix => pathname === prefix || pathname.startsWith(prefix + "/")) ||
|
||||
(role === "admin" && (pathname.startsWith("/dashboard/supervisor/") || pathname.startsWith("/dashboard/staff/"))) ||
|
||||
(role === "supervisor" && pathname.startsWith("/dashboard/staff/"));
|
||||
|
||||
if (!isAllowed && pathname !== dest) {
|
||||
router.replace(dest);
|
||||
}
|
||||
}, [pathname, user, loading, router]);
|
||||
|
||||
// Don't render protected content until auth state is known
|
||||
if (loading || !user) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-sm text-gray-400">Loading…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Navbar />
|
||||
<div className="flex-1 flex flex-col md:flex-row">
|
||||
{/* Mobile dropdown navigation */}
|
||||
<MobileSidebar />
|
||||
{/* Desktop sidebar */}
|
||||
<div className="hidden md:block">
|
||||
<Sidebar />
|
||||
</div>
|
||||
<main className="flex-1 p-6 bg-gray-50">{children}</main>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
import React, { useEffect } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function DashboardIndex() {
|
||||
const { user, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return; // wait for auth to initialize
|
||||
if (!user) {
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
// Role-aware routing
|
||||
const role = user.role || "user";
|
||||
if (role === "admin") router.replace("/dashboard/admin");
|
||||
else if (role === "supervisor") router.replace("/dashboard/supervisor");
|
||||
else if (role === "staff") router.replace("/dashboard/staff");
|
||||
else router.replace("/dashboard/user");
|
||||
}, [router, user, loading]);
|
||||
|
||||
// This route only ever exists to redirect elsewhere (login now goes straight to the role
|
||||
// dashboard — see LoginForm — so this mainly serves bookmarks/direct links); show a deliberate
|
||||
// loading state instead of a blank screen while that redirect is worked out.
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-sm text-gray-400">Loading…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
"use client";
|
||||
|
||||
import React, { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { QrImage } from "@/components/shared/QrImage";
|
||||
|
||||
function EventTicketsContent() {
|
||||
const { token, user } = useAuth();
|
||||
const params = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
const paramEventId = params.get("eventId") || "";
|
||||
const paramUserId = params.get("userId") || "";
|
||||
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [eventId, setEventId] = useState<string>(paramEventId);
|
||||
const [tickets, setTickets] = useState<any[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>(paramUserId);
|
||||
const [selectedVariantId, setSelectedVariantId] = useState<string>("");
|
||||
|
||||
const canView = useMemo(() => {
|
||||
const role = user?.role;
|
||||
return role === "admin" || role === "supervisor" || role === "staff";
|
||||
}, [user]);
|
||||
|
||||
// Load events for dropdown
|
||||
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();
|
||||
return !isNaN(t) && t > now;
|
||||
});
|
||||
active.sort(
|
||||
(a, b) =>
|
||||
new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
|
||||
);
|
||||
setEvents(active);
|
||||
if (!paramEventId && active.length > 0) {
|
||||
setEventId(active[0].id);
|
||||
router.replace(
|
||||
`?eventId=${encodeURIComponent(active[0].id)}${
|
||||
selectedUserId ? `&userId=${encodeURIComponent(selectedUserId)}` : ""
|
||||
}`
|
||||
);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load events");
|
||||
}
|
||||
})();
|
||||
}, [token]);
|
||||
|
||||
// Sync params → state
|
||||
useEffect(() => {
|
||||
if (paramEventId && paramEventId !== eventId) setEventId(paramEventId);
|
||||
if (paramUserId && paramUserId !== selectedUserId)
|
||||
setSelectedUserId(paramUserId);
|
||||
}, [paramEventId, paramUserId]);
|
||||
|
||||
// Load tickets when event changes
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!token || !eventId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const list = await apiFetch<any[]>(
|
||||
`/api/tickets/event/${encodeURIComponent(eventId)}`,
|
||||
{ authToken: token }
|
||||
);
|
||||
setTickets(list);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load tickets");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [token, eventId]);
|
||||
|
||||
// Build variants list for filter (only variants present in current event's tickets)
|
||||
const variantsList = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const t of tickets) {
|
||||
const v = t.registrationOption?.variant;
|
||||
if (v?.id) map.set(v.id, v.name);
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.map(([id, name]) => ({ id, name }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [tickets]);
|
||||
|
||||
// Build users list depending on event selection
|
||||
const usersList = useMemo(() => {
|
||||
let source = [...tickets];
|
||||
if (eventId) {
|
||||
source = source.filter((t) => String(t.eventId) === String(eventId));
|
||||
}
|
||||
const map = new Map<string, string>();
|
||||
for (const t of source) {
|
||||
const uid = t.user?.id || t.userId;
|
||||
if (!uid) continue;
|
||||
const name = t.user?.name || t.user?.email || String(uid);
|
||||
if (!map.has(String(uid))) map.set(String(uid), name);
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.map(([id, name]) => ({ id, name }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [tickets, eventId]);
|
||||
|
||||
// Filter tickets by event + user + variant
|
||||
const filteredTickets = useMemo(() => {
|
||||
let list = [...tickets];
|
||||
if (eventId) {
|
||||
list = list.filter((t) => String(t.eventId) === String(eventId));
|
||||
}
|
||||
if (selectedUserId) {
|
||||
list = list.filter(
|
||||
(t) => String(t.user?.id || t.userId) === String(selectedUserId)
|
||||
);
|
||||
}
|
||||
if (selectedVariantId) {
|
||||
list = list.filter(
|
||||
(t) => String(t.registrationOption?.variant?.id || "") === String(selectedVariantId)
|
||||
);
|
||||
}
|
||||
return list;
|
||||
}, [tickets, eventId, selectedUserId, selectedVariantId]);
|
||||
|
||||
// Printing helpers
|
||||
const buildTicketHtmlCard = (t: any) => {
|
||||
const eventTitle = t.event?.title || t.eventId || "Event";
|
||||
const eventDate = t.event?.startDate ? new Date(t.event.startDate) : null;
|
||||
const optBase = t.registrationOption?.eventOption?.name || "Ticket";
|
||||
const optVariant = t.registrationOption?.variant?.name;
|
||||
const type = optVariant ? `${optBase} — ${optVariant}` : optBase;
|
||||
const qty = t.quantity || 1;
|
||||
const holder = t.user?.name || t.userId || "";
|
||||
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=140x140&data=${encodeURIComponent(
|
||||
t.qrCode || t.id
|
||||
)}`;
|
||||
const dateStr = eventDate ? formatDate(eventDate) : "";
|
||||
return `
|
||||
<div class="ticket">
|
||||
<div class="evt">${eventTitle}</div>
|
||||
${dateStr ? `<div class="date">${dateStr}</div>` : ""}
|
||||
<div class="type">${type}</div>
|
||||
<div class="qty">Qty: <strong>${qty}</strong></div>
|
||||
<div class="qrwrap"><img src="${qrSrc}" alt="QR" /></div>
|
||||
<div class="meta">ID: ${t.id}</div>
|
||||
${holder ? `<div class="meta">${holder}</div>` : ""}
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
const openPrintWindow = (pagesHtml: string) => {
|
||||
const w = window.open("", "_blank");
|
||||
if (!w) return;
|
||||
w.document.write(`<!doctype html><html><head><title>Tickets</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 10mm; }
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: Arial, Helvetica, sans-serif; margin: 0; padding: 0; }
|
||||
.page {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-rows: repeat(4, 1fr);
|
||||
height: 277mm;
|
||||
gap: 6mm;
|
||||
page-break-after: always;
|
||||
break-after: page;
|
||||
}
|
||||
.page:last-child { page-break-after: avoid; break-after: avoid; }
|
||||
.ticket { border: 1px solid #000; padding: 5mm; display: flex; flex-direction: column; justify-content: space-between; overflow: hidden; }
|
||||
.evt { font-weight: bold; font-size: 14px; }
|
||||
.type { font-size: 12px; margin-top: 2mm; }
|
||||
.qty { font-size: 11px; margin-top: 1mm; }
|
||||
.date { font-size: 10px; color: #555; margin-top: 1mm; }
|
||||
.qrwrap { display: flex; justify-content: center; margin: 2mm 0; }
|
||||
.qrwrap img { width: 30mm; height: 30mm; }
|
||||
.meta { font-size: 10px; text-align: center; }
|
||||
</style>
|
||||
</head><body>
|
||||
${pagesHtml}
|
||||
<script>
|
||||
(function(){
|
||||
function printWhenReady(){
|
||||
var imgs = Array.prototype.slice.call(document.images);
|
||||
if(imgs.length === 0){ window.print(); return; }
|
||||
var remaining = imgs.length;
|
||||
function done(){ remaining--; if(remaining <= 0){ setTimeout(function(){ window.print(); }, 100); } }
|
||||
imgs.forEach(function(img){
|
||||
if (img.complete) { done(); }
|
||||
else {
|
||||
img.addEventListener('load', done, { once: true });
|
||||
img.addEventListener('error', done, { once: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
if (document.readyState === 'complete') printWhenReady();
|
||||
else window.addEventListener('load', printWhenReady);
|
||||
})();
|
||||
</script>
|
||||
</body></html>`);
|
||||
w.document.close();
|
||||
w.focus();
|
||||
};
|
||||
|
||||
const printTickets = (ticketList: any[]) => {
|
||||
if (!ticketList || ticketList.length === 0) return;
|
||||
// Chunk into pages of 8 (2 columns × 4 rows per A4 sheet)
|
||||
const PAGE_SIZE = 8;
|
||||
const pages: string[] = [];
|
||||
for (let i = 0; i < ticketList.length; i += PAGE_SIZE) {
|
||||
const chunk = ticketList.slice(i, i + PAGE_SIZE);
|
||||
pages.push(`<div class="page">${chunk.map(buildTicketHtmlCard).join("")}</div>`);
|
||||
}
|
||||
openPrintWindow(pages.join(""));
|
||||
};
|
||||
|
||||
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">Event Tickets</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 and print tickets for an event.
|
||||
</p>
|
||||
|
||||
{!canView && (
|
||||
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
|
||||
You need staff, supervisor, or admin access to view this page.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-2 mb-4">
|
||||
<label className="text-sm text-gray-700 shrink-0">Event</label>
|
||||
<select
|
||||
className="border rounded px-3 py-2 w-full sm:w-64 max-w-full"
|
||||
value={eventId}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setEventId(v);
|
||||
setSelectedUserId("");
|
||||
router.replace(
|
||||
`?eventId=${encodeURIComponent(v)}${
|
||||
selectedUserId
|
||||
? `&userId=${encodeURIComponent(selectedUserId)}`
|
||||
: ""
|
||||
}`
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option value="">All events</option>
|
||||
{events.map((ev) => (
|
||||
<option key={ev.id} value={ev.id}>
|
||||
{ev.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label className="text-sm text-gray-700 sm:ml-4 shrink-0">User</label>
|
||||
<select
|
||||
className="border rounded px-3 py-2 w-full sm:w-64 max-w-full"
|
||||
value={selectedUserId}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setSelectedUserId(v);
|
||||
router.replace(
|
||||
`?eventId=${encodeURIComponent(eventId || "")}${
|
||||
v ? `&userId=${encodeURIComponent(v)}` : ""
|
||||
}`
|
||||
);
|
||||
}}
|
||||
disabled={usersList.length === 0}
|
||||
>
|
||||
<option value="">All users</option>
|
||||
{usersList.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{variantsList.length > 0 && (
|
||||
<>
|
||||
<label className="text-sm text-gray-700 sm:ml-4 shrink-0">Variant</label>
|
||||
<select
|
||||
className="border rounded px-3 py-2 w-full sm:w-48 max-w-full"
|
||||
value={selectedVariantId}
|
||||
onChange={(e) => setSelectedVariantId(e.target.value)}
|
||||
>
|
||||
<option value="">All variants</option>
|
||||
{variantsList.map((v) => (
|
||||
<option key={v.id} value={v.id}>{v.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
|
||||
{filteredTickets.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => printTickets(filteredTickets)}
|
||||
className="px-3 py-2 border rounded"
|
||||
>
|
||||
Print all
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
printTickets(filteredTickets.filter((t) => !t.isUsed))
|
||||
}
|
||||
className="px-3 py-2 bg-blue-600 text-white rounded"
|
||||
>
|
||||
Print unused
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && <div>Loading...</div>}
|
||||
{error && <div className="text-red-600 text-sm">{error}</div>}
|
||||
|
||||
{filteredTickets.length > 0 && (
|
||||
<div className="mb-3 text-sm text-gray-700">
|
||||
<span className="mr-3">
|
||||
Total tickets: {filteredTickets.length}
|
||||
</span>
|
||||
<span className="mr-3">
|
||||
Used: {filteredTickets.filter((t) => t.isUsed).length}
|
||||
</span>
|
||||
<span>
|
||||
Unused: {filteredTickets.filter((t) => !t.isUsed).length}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{filteredTickets.map((t) => {
|
||||
const eventDate = t.event?.startDate
|
||||
? new Date(t.event.startDate)
|
||||
: null;
|
||||
const optBase = t.registrationOption?.eventOption?.name || "Ticket";
|
||||
const optVariant = t.registrationOption?.variant?.name;
|
||||
const type = optVariant ? `${optBase} — ${optVariant}` : optBase;
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
className="border rounded-xl p-3 space-y-2 bg-white shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="font-medium">
|
||||
{t.event?.title || t.eventId}
|
||||
</div>
|
||||
{eventDate && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{formatDate(eventDate)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-700">{type}</div>
|
||||
<div className="flex justify-center">
|
||||
<QrImage value={t.qrCode || t.id} size={120} className="w-28 h-28" />
|
||||
</div>
|
||||
<div className="text-center text-xs text-gray-500">
|
||||
#{String(t.id).slice(0, 8)} •{" "}
|
||||
{t.isUsed ? "Used" : "Unused"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EventTicketsPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="p-6">Loading...</div>}>
|
||||
<EventTicketsContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
"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 StaffDashboardPage() {
|
||||
const { user, loading, token } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
// Access control (staff/supervisor/admin)
|
||||
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]);
|
||||
|
||||
// Stats and recent scans for quick overview — both come from one endpoint
|
||||
// (/api/stats/staff) instead of two separate calls. 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 panel from flickering.
|
||||
const [stats, setStats] = useStableState<any | null>(null);
|
||||
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/staff", { authToken: token });
|
||||
setStats(data.scanStats);
|
||||
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 10s 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();
|
||||
}, 10000, !!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">Staff 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/staff/ticket-scanning")}>Open scanner</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/staff/event-tickets")}>Event tickets</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!canView && (
|
||||
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
|
||||
You need staff, supervisor, or admin access to use staff 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-2 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/staff/ticket-scanning")}>Scan tickets
|
||||
<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>
|
||||
</div>
|
||||
</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">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>
|
||||
|
||||
<div>
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-3">Scanner stats</h2>
|
||||
{loadingStats && <div className="text-sm text-gray-500 mb-2">Loading stats…</div>}
|
||||
{stats && (
|
||||
<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">{stats.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">{stats.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">{stats.lastHour}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats?.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">
|
||||
{stats.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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { QRScanner, QRScannerHandle } from "@/components/qr/QRScanner";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function TicketScanningPage() {
|
||||
const router = useRouter();
|
||||
const scannerRef = useRef<QRScannerHandle>(null);
|
||||
const [lastResult, setLastResult] = useState<string | null>(null);
|
||||
const [scanInfo, setScanInfo] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [alreadyUsedModal, setAlreadyUsedModal] = useState<{ message: string; ticket?: any } | null>(null);
|
||||
const [errorModal, setErrorModal] = useState<string | null>(null);
|
||||
const { token, user } = useAuth();
|
||||
const [recentScans, setRecentScans] = useState<any[]>([]);
|
||||
const [stats, setStats] = useState<any | null>(null);
|
||||
const [loadingStats, setLoadingStats] = useState<boolean>(false);
|
||||
const [successModal, setSuccessModal] = useState<{
|
||||
optionName: string;
|
||||
ticketId: string;
|
||||
eventTitle: string;
|
||||
qtyRedeemed: number;
|
||||
remaining: number;
|
||||
total: number;
|
||||
} | null>(null);
|
||||
|
||||
// Confirm step state
|
||||
const [confirmModal, setConfirmModal] = useState<{
|
||||
qrCode: string;
|
||||
ticket: any;
|
||||
remaining: number;
|
||||
} | null>(null);
|
||||
const [confirmQty, setConfirmQty] = useState(1);
|
||||
const [confirmQtyRaw, setConfirmQtyRaw] = useState("1");
|
||||
|
||||
// Event selection state
|
||||
const [allEvents, setAllEvents] = useState<any[]>([]);
|
||||
const [includePastEvents, setIncludePastEvents] = useState(false);
|
||||
const [selectedEventId, setSelectedEventId] = useState<string>("all");
|
||||
const [loadingEvents, setLoadingEvents] = useState<boolean>(false);
|
||||
const [sections, setSections] = useState<any[]>([]);
|
||||
const [selectedSectionId, setSelectedSectionId] = useState<string>("all");
|
||||
const [loadingSections, setLoadingSections] = useState(false);
|
||||
|
||||
// All events from API (including past); frontend filters to 12h window or all past
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
if (!token) return;
|
||||
setLoadingEvents(true);
|
||||
const evs = await apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token });
|
||||
const sorted = (evs || []).sort((a, b) => new Date(a.endDate).getTime() - new Date(b.endDate).getTime());
|
||||
setAllEvents(sorted);
|
||||
} catch (e: any) {
|
||||
// non-fatal
|
||||
} finally {
|
||||
setLoadingEvents(false);
|
||||
}
|
||||
})();
|
||||
}, [token]);
|
||||
|
||||
const events = useMemo(() => {
|
||||
if (includePastEvents) return allEvents;
|
||||
const cutoff = Date.now() - 12 * 60 * 60 * 1000;
|
||||
return allEvents.filter(ev => {
|
||||
const t = new Date(ev.endDate).getTime();
|
||||
return !isNaN(t) && t >= cutoff;
|
||||
});
|
||||
}, [allEvents, includePastEvents]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!token || !selectedEventId || selectedEventId === "all") {
|
||||
setSections([]);
|
||||
setSelectedSectionId("all");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setLoadingSections(true);
|
||||
const list = await apiFetch<any[]>(`/api/sections?eventId=${selectedEventId}`, { authToken: token });
|
||||
setSections(Array.isArray(list) ? list : []);
|
||||
setSelectedSectionId("all");
|
||||
} catch {
|
||||
setSections([]);
|
||||
} finally {
|
||||
setLoadingSections(false);
|
||||
}
|
||||
})();
|
||||
}, [selectedEventId, token]);
|
||||
|
||||
// Bug fix: stop the scanner when the event or section filter changes so stale scan
|
||||
// context is never used. The user must press "Scan Ticket" again to resume.
|
||||
const isFirstFilterRender = useRef(true);
|
||||
useEffect(() => {
|
||||
if (isFirstFilterRender.current) { isFirstFilterRender.current = false; return; }
|
||||
scannerRef.current?.stop();
|
||||
}, [selectedEventId, selectedSectionId]);
|
||||
|
||||
async function handleResult(text: string) {
|
||||
setLastResult(text);
|
||||
setError(null);
|
||||
setScanInfo(null);
|
||||
try {
|
||||
if (!token) {
|
||||
setErrorModal("Please login as staff to scan tickets.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Always preview first to validate the ticket before showing confirm step
|
||||
// Uses the lightweight scan-preview endpoint (only fetches fields needed for the confirm modal)
|
||||
let preview: any;
|
||||
try {
|
||||
preview = await apiFetch<any>(`/api/tickets/scan-preview/${encodeURIComponent(text)}`, { authToken: token });
|
||||
} catch (pe: any) {
|
||||
setErrorModal(pe?.message || 'Failed to validate ticket');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate event
|
||||
if (selectedEventId && selectedEventId !== "all") {
|
||||
const ticketEventId = preview?.event?.id || preview?.eventId;
|
||||
if (ticketEventId && ticketEventId !== selectedEventId) {
|
||||
const eventTitle = preview?.event?.title || 'Event';
|
||||
setErrorModal(`This ticket is for a different event: ${eventTitle}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate section
|
||||
if (selectedSectionId !== "all") {
|
||||
const ticketOptionId = preview?.registrationOption?.eventOption?.id || preview?.eventOptionId;
|
||||
if (!ticketOptionId) {
|
||||
setErrorModal("Unable to determine ticket type");
|
||||
return;
|
||||
}
|
||||
const section = sections.find(s => s.id === selectedSectionId);
|
||||
const allowedOptionIds = (section?.allowedOptions || []).map((o: any) => o.eventOptionId);
|
||||
if (!allowedOptionIds.includes(ticketOptionId)) {
|
||||
setErrorModal("This ticket is not valid for this section");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compute remaining qty
|
||||
const ticketQty = preview?.quantity || 1;
|
||||
const totalRedeemed = (preview?.usages || []).reduce((s: number, u: any) => s + (u.quantityRedeemed || 1), 0);
|
||||
const remaining = ticketQty - totalRedeemed;
|
||||
|
||||
if (remaining <= 0) {
|
||||
setAlreadyUsedModal({ message: 'Ticket has already been fully used', ticket: preview });
|
||||
return;
|
||||
}
|
||||
|
||||
// Show confirm step
|
||||
setConfirmQty(remaining);
|
||||
setConfirmQtyRaw(String(remaining));
|
||||
setConfirmModal({ qrCode: text, ticket: preview, remaining });
|
||||
} catch (e: any) {
|
||||
setErrorModal(e?.message || "Failed to scan ticket");
|
||||
}
|
||||
}
|
||||
|
||||
async function commitScan() {
|
||||
if (!confirmModal || !token) return;
|
||||
|
||||
// Capture everything before clearing modal state
|
||||
const { qrCode, ticket, remaining } = confirmModal;
|
||||
const qty = confirmQty;
|
||||
const baseName = ticket?.registrationOption?.eventOption?.name || "Ticket";
|
||||
const variantName = ticket?.registrationOption?.variant?.name;
|
||||
const optionName = variantName ? `${baseName} — ${variantName}` : baseName;
|
||||
const eventTitle = ticket?.event?.title || 'Event';
|
||||
const idPart = ticket?.id ? String(ticket.id).slice(0, 6) : '';
|
||||
const newRemaining = remaining - qty;
|
||||
|
||||
// Show success immediately — we already have all the data from the preview step.
|
||||
// The actual write happens in the background so the user sees an instant response.
|
||||
setConfirmModal(null);
|
||||
setScanInfo(`Ticket #${idPart} — ${optionName} — ${eventTitle}`);
|
||||
setSuccessModal({
|
||||
optionName,
|
||||
ticketId: idPart,
|
||||
eventTitle,
|
||||
qtyRedeemed: qty,
|
||||
remaining: newRemaining,
|
||||
total: ticket?.quantity || 1,
|
||||
});
|
||||
|
||||
// Background write — reverse the optimistic update on failure
|
||||
try {
|
||||
const url = `/api/tickets/scan/${encodeURIComponent(qrCode)}${
|
||||
selectedEventId && selectedEventId !== "all" ? `?eventId=${encodeURIComponent(selectedEventId)}` : ''
|
||||
}`;
|
||||
await apiFetch<any>(url, {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: { qty },
|
||||
});
|
||||
refreshStatsAndRecent();
|
||||
} catch (e: any) {
|
||||
setSuccessModal(null);
|
||||
setScanInfo(null);
|
||||
const raw = e?.message || "Failed to scan ticket";
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
const msg: string | undefined = parsed?.message;
|
||||
if (msg && /already\s*(been)?\s*used/i.test(msg)) {
|
||||
setAlreadyUsedModal({ message: msg, ticket: parsed?.ticket });
|
||||
return;
|
||||
}
|
||||
setErrorModal(msg || raw);
|
||||
} catch {
|
||||
setErrorModal(raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loadRecent = async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const list = await apiFetch<any[]>(`/api/tickets/scans/recent?limit=10`, { authToken: token });
|
||||
setRecentScans(list);
|
||||
} catch {}
|
||||
};
|
||||
const loadStats = async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
setLoadingStats(true);
|
||||
const s = await apiFetch<any>(`/api/tickets/scans/stats`, { authToken: token });
|
||||
setStats(s);
|
||||
} catch {} finally {
|
||||
setLoadingStats(false);
|
||||
}
|
||||
};
|
||||
const refreshStatsAndRecent = () => { loadRecent(); loadStats(); };
|
||||
useEffect(() => {
|
||||
refreshStatsAndRecent();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto w-full p-4">
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-semibold">Ticket Scanning</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">
|
||||
Use the button to start/stop scanning. The back camera will be used when available.
|
||||
</p>
|
||||
|
||||
{/* Event filter */}
|
||||
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-2 mb-3">
|
||||
<label className="text-sm text-gray-700 shrink-0">Event</label>
|
||||
<select
|
||||
className="border rounded px-3 py-2 w-full sm:w-56 max-w-full"
|
||||
value={selectedEventId}
|
||||
onChange={(e) => setSelectedEventId(e.target.value)}
|
||||
>
|
||||
<option value="all">All events</option>
|
||||
{events.map((ev) => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="flex items-center gap-1.5 text-sm text-gray-600 cursor-pointer">
|
||||
<input type="checkbox" checked={includePastEvents} onChange={e => setIncludePastEvents(e.target.checked)} />
|
||||
Include past events
|
||||
</label>
|
||||
{loadingEvents && <span className="text-xs text-gray-500">Loading events…</span>}
|
||||
</div>
|
||||
{sections.length > 0 && (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 mb-3">
|
||||
<label className="text-sm text-gray-700 shrink-0">Section</label>
|
||||
<select
|
||||
className="border rounded px-3 py-2 w-full sm:w-56 max-w-full"
|
||||
value={selectedSectionId}
|
||||
onChange={(e) => setSelectedSectionId(e.target.value)}
|
||||
>
|
||||
<option value="all">All sections</option>
|
||||
{sections.map(section => (
|
||||
<option key={section.id} value={section.id}>{section.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{loadingSections && <span className="text-xs text-gray-500">Loading sections…</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<QRScanner
|
||||
ref={scannerRef}
|
||||
onResult={handleResult}
|
||||
paused={!!(confirmModal || alreadyUsedModal || errorModal || successModal)}
|
||||
/>
|
||||
|
||||
{scanInfo && (
|
||||
<div className="mt-2 p-3 border rounded bg-green-50 text-green-800 text-sm">{scanInfo}</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="mt-2 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-3">Scanner Stats</h2>
|
||||
{loadingStats && <div className="text-sm text-gray-500 mb-2">Loading stats…</div>}
|
||||
{stats && (
|
||||
<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">{stats.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">{stats.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">{stats.lastHour}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats?.byStaff?.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="text-sm font-medium mb-1">Today by staff</div>
|
||||
<ul className="text-sm text-gray-700 space-y-1">
|
||||
{stats.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>
|
||||
)}
|
||||
|
||||
<h3 className="text-lg font-semibold mb-2">Last 10 scans</h3>
|
||||
<ul className="text-sm space-y-2 max-h-80 overflow-auto pr-2">
|
||||
{recentScans.map((u: any) => (
|
||||
<li key={u.id} className="border rounded p-2 bg-white">
|
||||
<div className="flex justify-between">
|
||||
<div className="font-medium">{u.ticket?.event?.title || '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'}{u.ticket?.registrationOption?.variant?.name ? ` — ${u.ticket.registrationOption.variant.name}` : ''}
|
||||
{u.quantityRedeemed && u.quantityRedeemed > 1 ? ` ×${u.quantityRedeemed}` : ''}
|
||||
{' — '}#{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>
|
||||
|
||||
{/* ── Confirm scan modal ───────────────────────────────────────────── */}
|
||||
{confirmModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-lg max-w-md w-full p-6">
|
||||
<h3 className="text-lg font-semibold mb-1 text-indigo-700">Confirm Scan</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">Review the ticket details before confirming.</p>
|
||||
|
||||
<div className="bg-gray-50 border rounded-lg p-4 mb-4 space-y-1 text-sm">
|
||||
<div><span className="font-medium">Ticket type:</span> {confirmModal.ticket?.registrationOption?.eventOption?.name || 'Ticket'}{confirmModal.ticket?.registrationOption?.variant?.name ? ` — ${confirmModal.ticket.registrationOption.variant.name}` : ''}</div>
|
||||
<div><span className="font-medium">Event:</span> {confirmModal.ticket?.event?.title || '—'}</div>
|
||||
<div><span className="font-medium">Holder:</span> {confirmModal.ticket?.user?.name || confirmModal.ticket?.registrationOption?.registration?.user?.name || '—'}</div>
|
||||
<div><span className="font-medium">Qty on ticket:</span> {confirmModal.ticket?.quantity || 1}</div>
|
||||
<div><span className="font-medium">Remaining:</span> <span className={confirmModal.remaining < (confirmModal.ticket?.quantity || 1) ? 'text-amber-600 font-semibold' : 'text-green-700 font-semibold'}>{confirmModal.remaining}</span></div>
|
||||
</div>
|
||||
|
||||
{(confirmModal.ticket?.quantity || 1) > 1 && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Qty to redeem (max {confirmModal.remaining})</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={confirmModal.remaining}
|
||||
className="w-24 border rounded px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
||||
value={confirmQtyRaw}
|
||||
onChange={e => setConfirmQtyRaw(e.target.value)}
|
||||
onBlur={() => {
|
||||
const parsed = parseInt(confirmQtyRaw, 10);
|
||||
const clamped = isNaN(parsed) ? 1 : Math.min(confirmModal.remaining, Math.max(1, parsed));
|
||||
setConfirmQty(clamped);
|
||||
setConfirmQtyRaw(String(clamped));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setConfirmModal(null)}
|
||||
className="flex-1 px-4 py-2 rounded-lg border text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={commitScan}
|
||||
className="flex-1 px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-semibold hover:bg-indigo-700"
|
||||
>
|
||||
Confirm Scan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Already used modal ───────────────────────────────────────────── */}
|
||||
{alreadyUsedModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="bg-white rounded shadow-lg max-w-md w-full p-5">
|
||||
<h3 className="text-lg font-semibold mb-2 text-red-600">Ticket Already Scanned</h3>
|
||||
<p className="text-sm text-gray-700 mb-3">{alreadyUsedModal.message}</p>
|
||||
{alreadyUsedModal.ticket && (
|
||||
<div className="text-xs text-gray-600 bg-gray-50 p-3 rounded border">
|
||||
<div><span className="font-medium">Ticket ID:</span> {alreadyUsedModal.ticket.id}</div>
|
||||
{alreadyUsedModal.ticket.event?.title && (
|
||||
<div><span className="font-medium">Event:</span> {alreadyUsedModal.ticket.event.title}</div>
|
||||
)}
|
||||
{Array.isArray(alreadyUsedModal.ticket.usages) && alreadyUsedModal.ticket.usages.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="font-medium">Usages:</div>
|
||||
<ul className="list-disc ml-5 mt-1 max-h-32 overflow-auto">
|
||||
{alreadyUsedModal.ticket.usages.map((u: any) => (
|
||||
<li key={u.id}>{new Date(u.scannedAt).toLocaleString()}{u.quantityRedeemed > 1 ? ` (×${u.quantityRedeemed})` : ''}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button className="px-4 py-2 rounded bg-red-600 text-white" onClick={() => setAlreadyUsedModal(null)}>OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Error modal ──────────────────────────────────────────────────── */}
|
||||
{errorModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="bg-white rounded shadow-lg max-w-md w-full p-5">
|
||||
<h3 className="text-lg font-semibold mb-2 text-red-600">Scan Error</h3>
|
||||
<p className="text-sm text-gray-700 mb-3">{errorModal}</p>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button className="px-4 py-2 rounded bg-red-600 text-white" onClick={() => setErrorModal(null)}>OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Success modal ────────────────────────────────────────────────── */}
|
||||
{successModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-lg max-w-md w-full p-6">
|
||||
<h3 className="text-lg font-semibold mb-4 text-green-600">Scan Successful</h3>
|
||||
|
||||
<div className="text-3xl font-bold text-center text-gray-900 mb-2">
|
||||
{successModal.optionName}
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-center text-gray-600 mb-3">
|
||||
#{successModal.ticketId} — {successModal.eventTitle}
|
||||
</div>
|
||||
|
||||
{successModal.total > 1 && (
|
||||
<div className={`text-center text-sm font-medium mb-4 ${successModal.remaining > 0 ? 'text-amber-600' : 'text-green-700'}`}>
|
||||
{successModal.qtyRedeemed} redeemed · {successModal.remaining} remaining of {successModal.total}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center">
|
||||
<button className="px-6 py-2 rounded bg-green-600 text-white" onClick={() => setSuccessModal(null)}>OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
export default function DonatePage() {
|
||||
const { token } = useAuth();
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [eventId, setEventId] = useState<string>("");
|
||||
const [amount, setAmount] = useState<string>("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const evs = await apiFetch<any[]>("/api/events");
|
||||
setEvents(evs);
|
||||
if (evs.length > 0) setEventId(evs[0].id);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load events");
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const submit = async () => {
|
||||
if (!token) { setError("Please login"); return; }
|
||||
const amt = parseFloat(amount);
|
||||
if (!(amt > 0)) { setError("Enter a valid amount"); return; }
|
||||
if (amt < 15) { setError("Minimum donation is R15"); return; }
|
||||
if (!eventId) { setError("Select an event"); return; }
|
||||
try {
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
setLoading(true);
|
||||
const res = await apiFetch<{ redirectUrl: string }>("/api/payments/yoco-checkout", {
|
||||
method: "POST",
|
||||
body: {
|
||||
eventId,
|
||||
amount: amt,
|
||||
successUrl: window.location.origin + "/payment/success",
|
||||
cancelUrl: window.location.origin + "/payment/cancel",
|
||||
failureUrl: window.location.origin + "/payment/failure",
|
||||
},
|
||||
authToken: token,
|
||||
});
|
||||
setInfo("Redirecting to payment...");
|
||||
window.location.href = res.redirectUrl;
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to start donation checkout");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-xl mx-auto w-full p-6">
|
||||
<h1 className="text-2xl font-semibold mb-4">Make a donation</h1>
|
||||
{error && <p className="text-red-600 text-sm mb-3">{error}</p>}
|
||||
{info && <p className="text-green-700 text-sm mb-3">{info}</p>}
|
||||
|
||||
<label className="block text-sm font-medium mb-1">Event</label>
|
||||
<select className="w-full border rounded px-3 py-2 mb-3" value={eventId} onChange={(e)=>setEventId(e.target.value)}>
|
||||
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title}</option>)}
|
||||
</select>
|
||||
|
||||
<label className="block text-sm font-medium mb-1">Amount (R)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="15"
|
||||
step="1"
|
||||
placeholder="Enter amount (min R15)"
|
||||
value={amount}
|
||||
onChange={(e)=>setAmount(e.target.value)}
|
||||
className="w-full border rounded px-3 py-2 mb-1"
|
||||
/>
|
||||
<p className="text-xs text-gray-600 mb-3">Minimum donation is R15.</p>
|
||||
|
||||
<button
|
||||
disabled={loading}
|
||||
onClick={submit}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-60"
|
||||
>{loading?"Starting checkout...":"Donate"}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
import React, { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
// Types for form fields
|
||||
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null };
|
||||
|
||||
function FormsContent() {
|
||||
const search = useSearchParams();
|
||||
const router = useRouter();
|
||||
const { token } = useAuth();
|
||||
const registrationId = search.get("registrationId");
|
||||
|
||||
const [registration, setRegistration] = useState<any | null>(null);
|
||||
const [eventForm, setEventForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
|
||||
// Local entry state for new responses
|
||||
const [formsData, setFormsData] = useState<Record<number, Record<string, string>>>({});
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!token || !registrationId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
try {
|
||||
const reg = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
setRegistration(reg);
|
||||
const ev = await apiFetch<any>(`/api/events/${encodeURIComponent(reg.eventId)}`);
|
||||
if (ev?.form) setEventForm(ev.form);
|
||||
// Load any saved draft
|
||||
try {
|
||||
const draft = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, { authToken: token });
|
||||
if (draft && draft.data && typeof draft.data === 'object') setFormsData(draft.data);
|
||||
} catch {}
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to load registration');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [token, registrationId]);
|
||||
|
||||
const submittedCount = useMemo(() => {
|
||||
return (registration?.formResponses || []).length;
|
||||
}, [registration]);
|
||||
|
||||
const requiredCount = useMemo(() => {
|
||||
if (!registration) return 0;
|
||||
try {
|
||||
return (registration.registrationOptions || [])
|
||||
.filter((ro: any) => ro.eventOption?.isMainTicket)
|
||||
.reduce((s: number, ro: any) => s + (ro.quantity || 0), 0);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}, [registration]);
|
||||
|
||||
const remaining = Math.max(0, requiredCount - submittedCount);
|
||||
|
||||
// Determine if all required fields are filled for each attendee form to be submitted
|
||||
const canSubmit = useMemo(() => {
|
||||
if (!eventForm || remaining <= 0) return false;
|
||||
const reqFields = (eventForm.fields || [])
|
||||
.filter(f => !!f.isRequired && f.type !== 'statement' && f.type !== 'paragraph')
|
||||
.map(f => f.id);
|
||||
for (let i = 0; i < remaining; 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;
|
||||
}, [eventForm, formsData, remaining]);
|
||||
|
||||
const submit = async () => {
|
||||
if (!token || !registrationId) return;
|
||||
try {
|
||||
setError(null); setInfo(null);
|
||||
const payload: any[] = [];
|
||||
for (let i = 0; i < remaining; i++) {
|
||||
payload.push({ answers: formsData[i] || {} });
|
||||
}
|
||||
if (payload.length === 0) {
|
||||
setInfo('No remaining attendee forms to submit.');
|
||||
return;
|
||||
}
|
||||
await apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/forms/responses`, {
|
||||
method: 'POST',
|
||||
authToken: token,
|
||||
body: { responses: payload }
|
||||
});
|
||||
setInfo('Attendee forms submitted successfully.');
|
||||
// reload registration to reflect new responses
|
||||
const reg = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
setRegistration(reg);
|
||||
setFormsData({});
|
||||
// On success, redirect to the user dashbaord
|
||||
router.push('/dashboard/user');
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to submit forms');
|
||||
}
|
||||
};
|
||||
|
||||
const saveDraft = async () => {
|
||||
if (!token || !registrationId) return;
|
||||
try {
|
||||
setError(null); setInfo(null);
|
||||
await apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, {
|
||||
method: 'PUT',
|
||||
authToken: token,
|
||||
body: { data: formsData }
|
||||
});
|
||||
setInfo('Draft saved. You can return later to finish.');
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to save draft');
|
||||
}
|
||||
};
|
||||
|
||||
if (!registrationId) return <div className="p-6">Missing registrationId.</div>;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto w-full p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-semibold">Attendee forms</h1>
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={() => router.push('/dashboard/user')}>Back</button>
|
||||
</div>
|
||||
|
||||
{loading && <div className="text-sm text-gray-600 mb-2">Loading…</div>}
|
||||
{error && <div className="text-sm text-red-600 mb-2">{error}</div>}
|
||||
{info && <div className="text-sm text-emerald-700 mb-2">{info}</div>}
|
||||
|
||||
{registration && (
|
||||
<div className="border rounded p-3 mb-4 bg-white">
|
||||
<div className="font-medium">{registration.event?.title || registration.eventId}</div>
|
||||
<div className="text-xs text-gray-600">Registration #{registration.id.slice(0,8)}</div>
|
||||
<div className="text-sm mt-1">Forms submitted: {submittedCount} / {requiredCount}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Existing responses (read-only list) */}
|
||||
{registration && (registration.formResponses || []).length > 0 && (
|
||||
<div className="border rounded p-3 mb-4 bg-gray-50">
|
||||
<div className="text-sm font-medium mb-2">Submitted responses</div>
|
||||
<ul className="space-y-2">
|
||||
{(registration.formResponses || []).map((resp: any, idx: number) => (
|
||||
<li key={resp.id} className="bg-white border rounded p-2">
|
||||
<div className="font-medium text-sm mb-1">Attendee {idx + 1}</div>
|
||||
{(resp.answers || []).length === 0 ? (
|
||||
<div className="text-xs text-gray-600">No answers recorded.</div>
|
||||
) : (
|
||||
<ul className="text-xs list-disc pl-5 space-y-0.5">
|
||||
{resp.answers.map((a: any) => (
|
||||
<li key={a.id}>
|
||||
<span className="text-gray-600">{eventForm?.fields.find(f => f.id === a.fieldId)?.label || (`Field ${a.fieldId}`)}</span>: {a.value}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remaining forms to fill */}
|
||||
{eventForm && remaining > 0 && (
|
||||
<div className="border rounded p-3 bg-gray-50">
|
||||
<div className="text-sm font-medium mb-2">Fill remaining attendee details ({remaining})</div>
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: remaining }, (_, idx) => (
|
||||
<div key={idx} className="bg-white border rounded p-3">
|
||||
<div className="font-medium mb-2">Attendee {submittedCount + idx + 1}</div>
|
||||
{eventForm.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 => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[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 => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[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 => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[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 => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))} />
|
||||
)}
|
||||
{f.helpText && <div className="text-xs text-gray-500 mt-1">{f.helpText}</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50" disabled={!canSubmit} onClick={submit}>Submit</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={saveDraft}>Save for later</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{eventForm && remaining === 0 && (
|
||||
<div className="text-sm text-emerald-700">All required attendee forms are completed for this registration.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FormsPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="p-6">Loading…</div>}>
|
||||
<FormsContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
import React, { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
function MakePaymentContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const registrationId = searchParams.get("registrationId");
|
||||
const { token } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
const [registration, setRegistration] = useState<any | null>(null);
|
||||
const [payments, setPayments] = useState<any[]>([]);
|
||||
const [amount, setAmount] = useState<string>("");
|
||||
const [priceUpdated, setPriceUpdated] = useState<{ newTotal: number; message: string } | null>(null);
|
||||
|
||||
// Compute effective unit price for a registration option.
|
||||
// Uses priceSnapshot when available (authoritative backend price, variant-aware).
|
||||
// Falls back to deadline-only early-bird calculation for legacy rows without a snapshot.
|
||||
const optionUnitPrice = (opt: any, referenceTime: any, atTime: Date): number => {
|
||||
if (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) {
|
||||
return Number(opt.priceSnapshot);
|
||||
}
|
||||
const eo = opt.eventOption;
|
||||
const variantId: string | null = opt.variantId || null;
|
||||
const base = (opt.variant?.price !== null && opt.variant?.price !== undefined)
|
||||
? Number(opt.variant.price)
|
||||
: Number(eo?.price || 0);
|
||||
const allTiers = Array.isArray(eo?.earlyBirdTiers) ? eo.earlyBirdTiers.slice() : [];
|
||||
const tiers = variantId
|
||||
? allTiers.filter((t: any) => t.variantId === variantId)
|
||||
: allTiers.filter((t: any) => !t.variantId);
|
||||
if (tiers.length === 0) return base;
|
||||
const t = atTime ? new Date(atTime) : new Date();
|
||||
const ref = referenceTime ? new Date(referenceTime) : t;
|
||||
const applicable = tiers
|
||||
.map((x: any) => ({ ...x, deadline: new Date(x.deadline) }))
|
||||
.filter((x: any) => (ref < x.deadline) && (t < x.deadline))
|
||||
.sort((a: any, b: any) => a.deadline.getTime() - b.deadline.getTime() || (a.order||0) - (b.order||0) || a.price - b.price);
|
||||
if (applicable.length === 0) return base;
|
||||
const price = Number(applicable[0].price);
|
||||
return (price >= 0) ? price : base;
|
||||
};
|
||||
|
||||
const totals = useMemo(() => {
|
||||
if (!registration) return { totalDue: 0, totalPaid: 0, outstanding: 0 };
|
||||
const now = new Date();
|
||||
// totalDue uses priceSnapshot — not time-dependent, consistent with what the backend charges
|
||||
const totalDue = (registration.registrationOptions || []).reduce((s: number, opt: any) => s + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
|
||||
const totalPaid = payments.reduce((s: number, p: any) => s + (p.amount || 0), 0);
|
||||
const outstanding = Math.max(0, totalDue - totalPaid);
|
||||
return { totalDue, totalPaid, outstanding };
|
||||
}, [registration, payments]);
|
||||
|
||||
const minAllowed = useMemo(() => {
|
||||
if (totals.outstanding <= 0) return 0;
|
||||
return totals.outstanding >= 15 ? 15 : totals.outstanding;
|
||||
}, [totals.outstanding]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!token || !registrationId) return;
|
||||
try {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
const reg = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
setRegistration(reg);
|
||||
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
setPayments(pays);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load registration");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [token, registrationId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (totals.outstanding <= 0) {
|
||||
setAmount("");
|
||||
}
|
||||
}, [totals.outstanding]);
|
||||
|
||||
const submit = async () => {
|
||||
if (!token || !registrationId) return;
|
||||
const amt = parseFloat(amount);
|
||||
if (!(amt > 0)) { setError("Enter a valid amount"); return; }
|
||||
if (amt > totals.outstanding) { setError(`Amount cannot exceed outstanding (R ${totals.outstanding.toFixed(2)})`); return; }
|
||||
if (amt < minAllowed) {
|
||||
if (totals.outstanding >= 15) {
|
||||
setError("Minimum payment is R15");
|
||||
} else {
|
||||
setError(`Please pay the remaining outstanding amount (R ${totals.outstanding.toFixed(2)})`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
setPriceUpdated(null);
|
||||
setLoading(true);
|
||||
const res = await apiFetch<{ redirectUrl?: string; priceUpdated?: boolean; newTotal?: number; message?: string }>("/api/payments/yoco-checkout", {
|
||||
method: "POST",
|
||||
body: {
|
||||
registrationId,
|
||||
amount: amt,
|
||||
successUrl: window.location.origin + "/payment/success",
|
||||
cancelUrl: window.location.origin + "/payment/cancel",
|
||||
failureUrl: window.location.origin + "/payment/failure",
|
||||
},
|
||||
authToken: token,
|
||||
});
|
||||
if (res.priceUpdated) {
|
||||
// Early-bird price changed — show warning and reload registration data
|
||||
setPriceUpdated({ newTotal: res.newTotal ?? 0, message: res.message ?? 'Prices have changed.' });
|
||||
// Reload registration so totals reflect updated priceSnapshot
|
||||
try {
|
||||
const reg = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
setRegistration(reg);
|
||||
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
setPayments(pays);
|
||||
} catch {}
|
||||
setAmount("");
|
||||
return;
|
||||
}
|
||||
setInfo("Redirecting to payment...");
|
||||
window.location.href = res.redirectUrl!;
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to create checkout");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!registrationId) return <div className="p-6">Missing registrationId.</div>;
|
||||
|
||||
return (
|
||||
<div className="max-w-xl mx-auto w-full p-6">
|
||||
<h1 className="text-2xl font-semibold mb-4">Make a payment</h1>
|
||||
{error && <p className="text-red-600 text-sm mb-3">{error}</p>}
|
||||
{info && <p className="text-green-700 text-sm mb-3">{info}</p>}
|
||||
{priceUpdated && (
|
||||
<div className="mb-4 p-3 border border-amber-300 bg-amber-50 rounded text-sm text-amber-800">
|
||||
<strong>Pricing has changed.</strong> {priceUpdated.message}
|
||||
<div className="mt-1">New outstanding: <strong>R {priceUpdated.newTotal.toFixed(2)}</strong></div>
|
||||
<button
|
||||
className="mt-2 text-xs underline text-amber-700 hover:text-amber-900"
|
||||
onClick={() => setPriceUpdated(null)}
|
||||
>OK, I understand — continue with new price</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{registration ? (
|
||||
<div className="border rounded p-3 mb-4">
|
||||
<div className="font-medium">{registration.event?.title || registration.eventId}</div>
|
||||
<div className="text-sm text-gray-600">Registration #{registration.id.slice(0,8)}</div>
|
||||
<div className="mt-2 text-sm">
|
||||
<div>Total: R {totals.totalDue.toFixed(2)}</div>
|
||||
<div>Paid: R {totals.totalPaid.toFixed(2)}</div>
|
||||
<div>Outstanding: <span className={totals.outstanding>0?"text-red-600":"text-green-700"}>R {totals.outstanding.toFixed(2)}</span></div>
|
||||
</div>
|
||||
{(() => {
|
||||
const tiers = (registration.registrationOptions || []).flatMap((ro: any) => Array.isArray(ro.eventOption?.earlyBirdTiers) ? ro.eventOption.earlyBirdTiers : []);
|
||||
const upcoming = tiers.map((t: any) => ({ ...t, deadline: new Date(t.deadline) })).filter((t: any) => new Date() < t.deadline).sort((a: any, b: any) => a.deadline.getTime() - b.deadline.getTime());
|
||||
if (upcoming.length === 0) return null;
|
||||
const d = upcoming[0].deadline as Date;
|
||||
const dateStr = d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: '2-digit' });
|
||||
return <div className="mt-2 text-xs text-amber-700">Early bird pricing applies if paid before {dateStr}.</div>;
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600 mb-4">Loading registration...</p>
|
||||
)}
|
||||
|
||||
<label className="block text-sm font-medium mb-1">Amount (R)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={minAllowed.toFixed(2)}
|
||||
max={totals.outstanding > 0 ? totals.outstanding.toFixed(2) : "0"}
|
||||
step="1"
|
||||
placeholder={totals.outstanding >= 15 ? "Enter amount (min R15)" : `Enter amount (max R ${totals.outstanding.toFixed(2)})`}
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
disabled={totals.outstanding <= 0}
|
||||
className="w-full border rounded px-3 py-2 mb-1 disabled:opacity-60"
|
||||
/>
|
||||
{totals.outstanding > 0 && (
|
||||
<p className="text-xs text-gray-600 mb-3">Minimum payment {totals.outstanding >= 15 ? "is R15" : `is the remaining outstanding amount (R ${totals.outstanding.toFixed(2)})`}.</p>
|
||||
)}
|
||||
<button
|
||||
disabled={loading || totals.outstanding <= 0}
|
||||
onClick={submit}
|
||||
className="bg-green-600 text-white px-4 py-2 rounded disabled:opacity-60"
|
||||
>{loading?"Creating checkout...": totals.outstanding <= 0 ? "No outstanding amount" : "Pay now"}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MakePaymentPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="p-6">Loading...</div>}>
|
||||
<MakePaymentContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { isValidZAPhone } from "@/lib/phone";
|
||||
|
||||
export default function UserProfilePage() {
|
||||
const { user, token, logout, updateToken } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!user && !token) router.replace("/login");
|
||||
}, [user, token, router]);
|
||||
|
||||
// ── Profile form ─────────────────────────────────────────────────────────
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [notifPref, setNotifPref] = useState<"email" | "whatsapp" | "both">("email");
|
||||
const [profileMsg, setProfileMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||
const [savingProfile, setSavingProfile] = useState(false);
|
||||
|
||||
const hasValidPhone = isValidZAPhone(phone);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setName(user.name || "");
|
||||
setEmail(user.email || "");
|
||||
setPhone(user.phoneNumber || "");
|
||||
setNotifPref(user.notificationPreference || "email");
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const saveProfile = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!token) return;
|
||||
setSavingProfile(true);
|
||||
setProfileMsg(null);
|
||||
try {
|
||||
const res = await apiFetch<any>("/api/users/profile", {
|
||||
method: "PUT",
|
||||
authToken: token,
|
||||
body: {
|
||||
name,
|
||||
email,
|
||||
phoneNumber: phone || null,
|
||||
notificationPreference: hasValidPhone ? notifPref : "email",
|
||||
},
|
||||
});
|
||||
// Backend returns a refreshed token — save it so the session stays valid
|
||||
if (res?.token) updateToken(res.token);
|
||||
setProfileMsg({ type: "ok", text: "Profile updated." });
|
||||
} catch (e: any) {
|
||||
setProfileMsg({ type: "err", text: e?.message || "Failed to update profile." });
|
||||
} finally {
|
||||
setSavingProfile(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Password change ───────────────────────────────────────────────────────
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [pwMsg, setPwMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||
const [savingPw, setSavingPw] = useState(false);
|
||||
|
||||
const changePassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (newPassword !== confirmPassword) {
|
||||
setPwMsg({ type: "err", text: "Passwords do not match." });
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
setPwMsg({ type: "err", text: "Password must be at least 8 characters." });
|
||||
return;
|
||||
}
|
||||
if (!token) return;
|
||||
setSavingPw(true);
|
||||
setPwMsg(null);
|
||||
try {
|
||||
const res = await apiFetch<any>("/api/users/profile", {
|
||||
method: "PUT",
|
||||
authToken: token,
|
||||
body: { currentPassword, password: newPassword },
|
||||
});
|
||||
if (res?.token) updateToken(res.token);
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setPwMsg({ type: "ok", text: "Password changed." });
|
||||
} catch (e: any) {
|
||||
setPwMsg({ type: "err", text: e?.message || "Failed to change password." });
|
||||
} finally {
|
||||
setSavingPw(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Revoke sessions ───────────────────────────────────────────────────────
|
||||
const [revokeMsg, setRevokeMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||
const [revoking, setRevoking] = useState(false);
|
||||
|
||||
const revokeSessions = async () => {
|
||||
if (!confirm("This will sign you out of all other devices. You will need to log in again everywhere except here. Continue?")) return;
|
||||
if (!token) return;
|
||||
setRevoking(true);
|
||||
setRevokeMsg(null);
|
||||
try {
|
||||
const res = await apiFetch<any>("/api/users/revoke-sessions", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
});
|
||||
// Revoking sessions invalidates the token this device was using too —
|
||||
// save the freshly issued one so this device stays signed in.
|
||||
if (res?.token) updateToken(res.token);
|
||||
setRevokeMsg({ type: "ok", text: res?.message || "All other sessions signed out." });
|
||||
} catch (e: any) {
|
||||
setRevokeMsg({ type: "err", text: e?.message || "Failed to revoke sessions." });
|
||||
} finally {
|
||||
setRevoking(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Account closure ───────────────────────────────────────────────────────
|
||||
const [closeStep, setCloseStep] = useState<"idle" | "confirm">("idle");
|
||||
const [deleteData, setDeleteData] = useState(false);
|
||||
const [closePassword, setClosePassword] = useState("");
|
||||
const [closeMsg, setCloseMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||
const [closing, setClosing] = useState(false);
|
||||
|
||||
const submitAccountClosure = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!token) return;
|
||||
setClosing(true);
|
||||
setCloseMsg(null);
|
||||
try {
|
||||
const res = await apiFetch<any>("/api/users/close-account", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: { password: closePassword, deleteData },
|
||||
});
|
||||
setCloseMsg({ type: "ok", text: res?.message || "Account closed." });
|
||||
// Slight delay so the user can read the message, then log out
|
||||
setTimeout(() => logout(), 2500);
|
||||
} catch (e: any) {
|
||||
setCloseMsg({ type: "err", text: e?.message || "Failed to close account." });
|
||||
} finally {
|
||||
setClosing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Shared helpers ────────────────────────────────────────────────────────
|
||||
const Alert = ({ msg }: { msg: { type: "ok" | "err"; text: string } }) => (
|
||||
<div className={`mt-3 p-3 rounded text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>
|
||||
{msg.text}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto w-full p-6 space-y-8">
|
||||
<h1 className="text-2xl font-semibold">Profile & Security</h1>
|
||||
|
||||
{/* ── Profile info ─────────────────────────────────────────────────── */}
|
||||
<section className="border rounded-xl p-5 bg-white shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4">Personal information</h2>
|
||||
<form onSubmit={saveProfile} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Full name</label>
|
||||
<input
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email address</label>
|
||||
<input
|
||||
type="email"
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Phone number</label>
|
||||
<input
|
||||
type="tel"
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
value={phone}
|
||||
onChange={e => setPhone(e.target.value)}
|
||||
placeholder="e.g. 082 123 4567"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasValidPhone && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Notification preference</label>
|
||||
<select
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
value={notifPref}
|
||||
onChange={e => setNotifPref(e.target.value as "email" | "whatsapp" | "both")}
|
||||
>
|
||||
<option value="email">Email only</option>
|
||||
<option value="whatsapp">WhatsApp only</option>
|
||||
<option value="both">Email & WhatsApp</option>
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-500">Security alerts (password reset, login notifications) are always sent via email regardless of this setting.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={savingProfile}
|
||||
className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{savingProfile ? "Saving…" : "Save changes"}
|
||||
</button>
|
||||
{profileMsg && <Alert msg={profileMsg} />}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* ── Password ─────────────────────────────────────────────────────── */}
|
||||
<section className="border rounded-xl p-5 bg-white shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4">Change password</h2>
|
||||
<form onSubmit={changePassword} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Current password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
value={currentPassword}
|
||||
onChange={e => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">New password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm new password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={savingPw}
|
||||
className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{savingPw ? "Changing…" : "Change password"}
|
||||
</button>
|
||||
{pwMsg && <Alert msg={pwMsg} />}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* ── Security ─────────────────────────────────────────────────────── */}
|
||||
<section className="border rounded-xl p-5 bg-white shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-1">Security</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
If you suspect someone else has access to your account, you can sign out of all other devices immediately.
|
||||
You will remain logged in on this device.
|
||||
</p>
|
||||
<button
|
||||
onClick={revokeSessions}
|
||||
disabled={revoking}
|
||||
className="px-4 py-2 rounded-lg bg-amber-500 text-white text-sm font-medium hover:bg-amber-600 disabled:opacity-50"
|
||||
>
|
||||
{revoking ? "Signing out…" : "Sign out all other devices"}
|
||||
</button>
|
||||
{revokeMsg && <Alert msg={revokeMsg} />}
|
||||
</section>
|
||||
|
||||
{/* ── Danger zone ──────────────────────────────────────────────────── */}
|
||||
<section className="border border-red-200 rounded-xl p-5 bg-white shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-red-700 mb-1">Close account</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Closing your account will deactivate it immediately. You can also request that your personal
|
||||
information (name, email, phone number) be permanently deleted. Tickets and payment records
|
||||
will remain for accounting purposes but will show as "Deleted User".
|
||||
</p>
|
||||
|
||||
{closeStep === "idle" && (
|
||||
<button
|
||||
onClick={() => setCloseStep("confirm")}
|
||||
className="px-4 py-2 rounded-lg border border-red-600 text-red-600 text-sm font-medium hover:bg-red-50"
|
||||
>
|
||||
Close my account…
|
||||
</button>
|
||||
)}
|
||||
|
||||
{closeStep === "confirm" && (
|
||||
<form onSubmit={submitAccountClosure} className="space-y-4">
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800">
|
||||
<strong>This action cannot be undone.</strong> Please read carefully before continuing.
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
checked={deleteData}
|
||||
onChange={e => setDeleteData(e.target.checked)}
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
<span className="font-medium">Also delete my personal data</span> — your name, email address,
|
||||
and phone number will be permanently removed and cannot be recovered.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Confirm with your password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
value={closePassword}
|
||||
onChange={e => setClosePassword(e.target.value)}
|
||||
required
|
||||
placeholder="Enter your password to confirm"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setCloseStep("idle"); setClosePassword(""); setDeleteData(false); setCloseMsg(null); }}
|
||||
className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={closing || !closePassword}
|
||||
className="px-4 py-2 rounded-lg bg-red-600 text-white text-sm font-medium hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{closing ? "Processing…" : deleteData ? "Delete my data & close account" : "Close my account"}
|
||||
</button>
|
||||
</div>
|
||||
{closeMsg && <Alert msg={closeMsg} />}
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const { token } = useAuth();
|
||||
const router = useRouter();
|
||||
const [current, setCurrent] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const canSubmit = useMemo(() => current.length > 0 && password.length >= 8 && password === confirm, [current, password, confirm]);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setStatus(null);
|
||||
setError(null);
|
||||
if (!token) {
|
||||
setError("You must be logged in to change your password.");
|
||||
return;
|
||||
}
|
||||
if (!canSubmit) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
await apiFetch("/api/users/profile", { method: "PUT", authToken: token, body: { password, currentPassword: current } });
|
||||
setStatus("Your password has been updated successfully.");
|
||||
setCurrent("");
|
||||
setPassword("");
|
||||
setConfirm("");
|
||||
setTimeout(() => router.push("/dashboard/user"), 1200);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to update password");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto w-full p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-semibold">Reset password</h1>
|
||||
<button
|
||||
className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
|
||||
onClick={() => router.push("/dashboard/user")}
|
||||
>Back to dashboard</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-600 text-sm mb-3">{error}</p>}
|
||||
{status && <p className="text-green-700 text-sm mb-3">{status}</p>}
|
||||
|
||||
<div className="border rounded-xl p-5 bg-white shadow-sm">
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Current password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={current}
|
||||
onChange={e => setCurrent(e.target.value)}
|
||||
required
|
||||
className="w-full border rounded px-3 py-2"
|
||||
placeholder="Enter your current password"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">For your security, please confirm your current password.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">New password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
className="w-full border rounded px-3 py-2"
|
||||
placeholder="At least 8 characters"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Confirm new password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={e => setConfirm(e.target.value)}
|
||||
required
|
||||
className="w-full border rounded px-3 py-2"
|
||||
/>
|
||||
{password && confirm && password !== confirm && (
|
||||
<p className="text-xs text-red-600 mt-1">Passwords do not match.</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit || loading}
|
||||
className="px-4 py-2 rounded bg-blue-600 text-white disabled:opacity-60"
|
||||
>{loading ? "Saving…" : "Update password"}</button>
|
||||
<button
|
||||
type="button"
|
||||
className="px-3 py-2 rounded bg-gray-100 hover:bg-gray-200 text-gray-800"
|
||||
onClick={() => router.push("/dashboard/user")}
|
||||
>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user