Files
hope-events/frontend/src/app/dashboard/supervisor/event-options/page.tsx
T
joshuaandClaude Sonnet 5 8e6cb542d9 Full site redesign, help system, and dashboard stats fixes
Multi-phase visual facelift (design tokens, dashboards, sidebar/navbar
shell, per-page help guides, and a layout/content pass across every
remaining page) plus backend fixes to the dashboard KPI stats:

- Admin/Supervisor dashboard KPIs (revenue, donations, registrations,
  tickets sold) now use a rolling trailing-month window (today back one
  calendar month, e.g. 9 May - 8 June if today is 8 June) instead of
  calendar month-to-date, which under-counted for most of the month.
  The comparison window shifts the same way, so like is still compared
  with like.
- Reports deep-links from those stat tiles now match the same window
  (range=trailing_month, replacing range=this_month).
- Design tokens (brand-* Tailwind scale + shadcn CSS variables), a
  site-wide contextual help button, fixed dashboard sidebar/navbar,
  Admin/Supervisor/Staff/User dashboard rebuilds backed by a new
  GET /api/stats/overview endpoint, a dedicated Contact page, Site
  Settings restyle with WhatsApp config folded in, and an Account
  activity feed backed by a new SecurityEvent model.
- Every remaining page (home, events, registration flow, auth, legal,
  payment results, and every Admin/Supervisor/Staff/User tool page)
  restyled onto the same design tokens, several with real layout
  upgrades (home hero, events list/detail, donate page, auth pages).
- 20+ new dedicated help guides so the whole site has page-specific
  help content instead of falling back to a generic guide.
- Assorted fixes surfaced along the way: donation-leg double-counting
  in payment stats, donations not counting toward revenue, refund
  netting in per-method report breakdowns, and donation
  over-allocation after a refund.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 15:00:10 +02:00

305 lines
13 KiB
TypeScript

"use client";
import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Ticket } from "lucide-react";
// Format a Date (or date-like input) to the value expected by <input type="datetime-local">
// This returns local time (browser timezone) as YYYY-MM-DDTHH:mm
function toLocalDateTimeInputValue(input: string | number | Date | null | undefined): string {
if (input == null) return "";
const d = new Date(input);
if (isNaN(d.getTime())) return "";
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
return `${y}-${m}-${day}T${hh}:${mm}`;
}
function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers: { deadline: string; price: number; order?: number }[]) => void }) {
const [rows, setRows] = React.useState<{ id?: string; deadline: string; price: string; order?: number }[]>([]);
const [open, setOpen] = React.useState(false);
const [saving, setSaving] = React.useState(false);
React.useEffect(() => {
const tiers = Array.isArray(option?.earlyBirdTiers) ? option.earlyBirdTiers : [];
const normalized = tiers
.slice()
.sort((a: any, b: any) => new Date(a.deadline).getTime() - new Date(b.deadline).getTime() || (a.order || 0) - (b.order || 0))
.map((t: any, i: number) => ({ id: t.id, deadline: toLocalDateTimeInputValue(t.deadline), price: String(t.price ?? ''), order: typeof t.order === 'number' ? t.order : i }));
setRows(normalized);
}, [option?.id, option?.earlyBirdTiers]);
const addRow = () => {
setRows((r) => [...r, { deadline: '', price: '', order: (r.length || 0) }]);
};
const removeRow = (idx: number) => {
const copy = rows.slice();
copy.splice(idx, 1);
setRows(copy);
};
const updateRow = (idx: number, patch: Partial<{ deadline: string; price: string; order?: number }>) => {
const copy = rows.slice();
copy[idx] = { ...copy[idx], ...patch } as any;
setRows(copy);
};
const save = async () => {
setSaving(true);
try {
const tiers = rows
.filter((r) => !!r.deadline && String(r.price).trim() !== '')
.map((r, i) => ({ deadline: new Date(r.deadline).toISOString(), price: parseFloat(r.price), order: typeof r.order === 'number' ? r.order : i }))
.filter((t) => t.price >= 0 && !isNaN(new Date(t.deadline).getTime()));
onSave(tiers);
} finally {
setSaving(false);
}
};
return (
<div className="mt-3 border-t pt-3">
<button type="button" className="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200" onClick={() => setOpen(!open)}>
{open ? 'Hide early-bird tiers' : 'Manage early-bird tiers'}
</button>
{open && (
<div className="mt-2 bg-gray-50 border rounded p-2">
{rows.length === 0 ? (
<div className="text-xs text-gray-600 mb-2">No tiers yet. Add deadlines and prices for early-bird discounts.</div>
) : (
<ul className="space-y-2 mb-2">
{rows.map((row, idx) => (
<li key={idx} className="bg-white border rounded p-2">
<div className="flex flex-wrap items-center gap-2">
<input className="border rounded px-2 py-1 text-xs" type="datetime-local" value={row.deadline} onChange={(e) => updateRow(idx, { deadline: e.target.value })} />
<input className="border rounded px-2 py-1 text-xs w-24" type="number" step="0.01" value={row.price} onChange={(e) => updateRow(idx, { price: e.target.value })} placeholder="Price" />
<input className="border rounded px-2 py-1 text-xs w-16" type="number" step="1" value={typeof row.order === 'number' ? String(row.order) : ''} onChange={(e) => updateRow(idx, { order: parseInt(e.target.value || '0', 10) })} placeholder="#" />
<button type="button" className="text-xs px-2 py-1 rounded bg-red-600 text-white hover:bg-red-700" onClick={() => removeRow(idx)}>Remove</button>
</div>
</li>
))}
</ul>
)}
<div className="flex items-center gap-2">
<button type="button" className="text-xs px-2 py-1 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={addRow}>Add tier</button>
<button type="button" className="text-xs px-2 py-1 rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-60" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save tiers'}</button>
</div>
</div>
)}
</div>
);
}
function EventOptionsContent() {
const { user, loading, token } = useAuth();
const router = useRouter();
const search = useSearchParams();
const canView = useMemo(() => {
const role = user?.role;
return role === "admin" || role === "supervisor";
}, [user]);
useEffect(() => {
if (loading) return;
if (!user) router.replace("/login");
}, [user, loading, router]);
const [events, setEvents] = useState<any[]>([]);
const [selectedEventId, setSelectedEventId] = useState<string>("");
const [options, setOptions] = useState<any[]>([]);
const [loadingEv, setLoadingEv] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const loadEvents = async () => {
if (!token) return;
try {
setLoadingEv(true);
const evs = await apiFetch<any[]>("/api/events/all", { authToken: token });
setEvents(evs || []);
} catch (e) {
// ignore
} finally {
setLoadingEv(false);
}
};
useEffect(() => { loadEvents(); }, [token]);
// Preselect event from query (?eventId=)
useEffect(() => {
const q = search?.get("eventId");
if (!q) return;
// if events already loaded, ensure it exists then set; otherwise, set directly and let options hook handle
setSelectedEventId(q);
}, [search]);
useEffect(() => {
const ev = events.find(e => e.id === selectedEventId);
if (ev) {
setOptions(ev.options || ev.eventOptions || []);
} else {
setOptions([]);
}
}, [selectedEventId, events]);
const [newOpt, setNewOpt] = useState({ name: "", price: "", isMainTicket: false });
const createOption = async () => {
if (!token || !selectedEventId) return;
setError(null); setInfo(null);
if (!newOpt.name) { setError("Option name is required"); return; }
const priceNum = parseFloat(newOpt.price || "0");
try {
await apiFetch(`/api/events/${encodeURIComponent(selectedEventId)}/options`, {
method: "POST",
authToken: token,
body: { name: newOpt.name, price: priceNum || 0, isMainTicket: !!newOpt.isMainTicket }
});
setNewOpt({ name: "", price: "", isMainTicket: false });
setInfo("Option created");
await loadEvents();
} catch (e: any) {
setError(e?.message || "Failed to create option");
}
};
const updateOption = async (opt: any, patch: any) => {
if (!token) return;
setError(null); setInfo(null);
try {
await apiFetch(`/api/events/options/${encodeURIComponent(opt.id)}`, {
method: "PUT",
authToken: token,
body: patch
});
setInfo("Option updated");
await loadEvents();
} catch (e: any) {
setError(e?.message || "Failed to update option");
}
};
const deleteOption = async (opt: any) => {
if (!token) return;
setError(null); setInfo(null);
try {
await apiFetch(`/api/events/options/${encodeURIComponent(opt.id)}`, {
method: "DELETE",
authToken: token,
});
setInfo("Option deleted");
await loadEvents();
} catch (e: any) {
setError(e?.message || "Failed to delete option");
}
};
return (
<div className="max-w-5xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<Ticket className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">Event options</h1>
</div>
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard/supervisor/events')}>Back</button>
</div>
{!canView && (
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
You need supervisor or admin access to use this page.
</div>
)}
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
{info && <div className="p-3 mb-3 border rounded bg-emerald-50 text-emerald-800 text-sm">{info}</div>}
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
<div className="text-lg font-semibold mb-3">Select event</div>
<select className="w-full border rounded px-3 py-2 text-sm" value={selectedEventId} onChange={e => setSelectedEventId(e.target.value)}>
<option value="">Select an event</option>
{events.map(ev => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
{selectedEventId && (
<div className="mt-3 text-xs text-gray-600">
{(() => {
const ev = events.find(e => e.id === selectedEventId);
if (!ev) return null;
return <>
<div>{new Date(ev.startDate).toLocaleString()} - {new Date(ev.endDate).toLocaleString()}</div>
</>;
})()}
</div>
)}
</div>
{selectedEventId && (
<div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 border rounded-xl p-4 bg-white shadow-sm">
<div className="text-lg font-semibold mb-3">Options</div>
{options.length === 0 ? (
<div className="text-sm text-gray-500">No options yet.</div>
) : (
<ul className="space-y-3">
{options.map(opt => (
<li key={opt.id} className="border rounded p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-medium text-sm">{opt.name}</div>
<div className="text-xs text-gray-500">Price: R {(opt.price || 0).toFixed(2)} {opt.isMainTicket ? "• Main ticket" : ""}</div>
</div>
<div className="flex items-center gap-2">
<input className="w-36 border rounded px-2 py-1 text-sm" defaultValue={opt.name} onBlur={e => { const v = e.target.value.trim(); if (v && v !== opt.name) updateOption(opt, { name: v }); }} />
<input className="w-28 border rounded px-2 py-1 text-sm" type="number" step="0.01" defaultValue={(opt.price || 0)} onBlur={e => { const v = parseFloat(e.target.value || '0'); if (!isNaN(v) && v !== opt.price) updateOption(opt, { price: v }); }} />
<label className="text-xs flex items-center gap-1"><input type="checkbox" defaultChecked={!!opt.isMainTicket} onChange={e => updateOption(opt, { isMainTicket: e.target.checked })} /> Main</label>
{user?.role === 'admin' && (
<button onClick={() => deleteOption(opt)} className="text-xs px-2 py-1 rounded bg-red-600 text-white hover:bg-red-700">
Delete
</button>
)}
</div>
</div>
{/* Early Bird Tiers Editor */}
<EarlyBirdTiersEditor
option={opt}
onSave={(tiers) => updateOption(opt, { earlyBirdTiers: tiers })}
/>
</li>
))}
</ul>
)}
</div>
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="text-lg font-semibold mb-3">Create new option</div>
<div className="grid gap-2">
<input className="border rounded px-3 py-2 text-sm" placeholder="Name" value={newOpt.name} onChange={e => setNewOpt({ ...newOpt, name: e.target.value })} />
<input className="border rounded px-3 py-2 text-sm" placeholder="Price" type="number" step="0.01" value={newOpt.price} onChange={e => setNewOpt({ ...newOpt, price: e.target.value })} />
<label className="text-sm flex items-center gap-2"><input type="checkbox" checked={newOpt.isMainTicket} onChange={e => setNewOpt({ ...newOpt, isMainTicket: e.target.checked })} /> Main ticket</label>
<button onClick={createOption} className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 shadow-sm">Create option</button>
</div>
</div>
</div>
)}
</div>
);
}
export default function EventOptionsPage() {
return (
<Suspense fallback={<div className="p-6">Loading...</div>}>
<EventOptionsContent />
</Suspense>
);
}