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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user