Files
hope-events/frontend/src/app/dashboard/staff/event-tickets/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

405 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useRouter, useSearchParams } from "next/navigation";
import { formatDate } from "@/lib/date";
import { QrImage } from "@/components/shared/QrImage";
import { Ticket } from "lucide-react";
function EventTicketsContent() {
const { token, user } = useAuth();
const params = useSearchParams();
const router = useRouter();
const paramEventId = params.get("eventId") || "";
const paramUserId = params.get("userId") || "";
const [events, setEvents] = useState<any[]>([]);
const [eventId, setEventId] = useState<string>(paramEventId);
const [tickets, setTickets] = useState<any[]>([]);
const [error, setError] = useDismissingState<string | null>(null);
const [loading, setLoading] = useState(false);
const [selectedUserId, setSelectedUserId] = useState<string>(paramUserId);
const [selectedVariantId, setSelectedVariantId] = useState<string>("");
const canView = useMemo(() => {
const role = user?.role;
return role === "admin" || role === "supervisor" || role === "staff";
}, [user]);
// Load events for dropdown
useEffect(() => {
(async () => {
try {
if (!token) return;
const evs = await apiFetch<any[]>("/api/events/all", { authToken: token });
const now = Date.now();
const active = (evs || []).filter((ev) => {
const t = new Date(ev.endDate).getTime();
return !isNaN(t) && t > now;
});
active.sort(
(a, b) =>
new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
);
setEvents(active);
if (!paramEventId && active.length > 0) {
setEventId(active[0].id);
router.replace(
`?eventId=${encodeURIComponent(active[0].id)}${
selectedUserId ? `&userId=${encodeURIComponent(selectedUserId)}` : ""
}`
);
}
} catch (e: any) {
setError(e?.message || "Failed to load events");
}
})();
}, [token]);
// Sync params → state
useEffect(() => {
if (paramEventId && paramEventId !== eventId) setEventId(paramEventId);
if (paramUserId && paramUserId !== selectedUserId)
setSelectedUserId(paramUserId);
}, [paramEventId, paramUserId]);
// Load tickets when event changes
useEffect(() => {
(async () => {
if (!token || !eventId) return;
setLoading(true);
setError(null);
try {
const list = await apiFetch<any[]>(
`/api/tickets/event/${encodeURIComponent(eventId)}`,
{ authToken: token }
);
setTickets(list);
} catch (e: any) {
setError(e?.message || "Failed to load tickets");
} finally {
setLoading(false);
}
})();
}, [token, eventId]);
// Build variants list for filter (only variants present in current event's tickets)
const variantsList = useMemo(() => {
const map = new Map<string, string>();
for (const t of tickets) {
const v = t.registrationOption?.variant;
if (v?.id) map.set(v.id, v.name);
}
return Array.from(map.entries())
.map(([id, name]) => ({ id, name }))
.sort((a, b) => a.name.localeCompare(b.name));
}, [tickets]);
// Build users list depending on event selection
const usersList = useMemo(() => {
let source = [...tickets];
if (eventId) {
source = source.filter((t) => String(t.eventId) === String(eventId));
}
const map = new Map<string, string>();
for (const t of source) {
const uid = t.user?.id || t.userId;
if (!uid) continue;
const name = t.user?.name || t.user?.email || String(uid);
if (!map.has(String(uid))) map.set(String(uid), name);
}
return Array.from(map.entries())
.map(([id, name]) => ({ id, name }))
.sort((a, b) => a.name.localeCompare(b.name));
}, [tickets, eventId]);
// Filter tickets by event + user + variant
const filteredTickets = useMemo(() => {
let list = [...tickets];
if (eventId) {
list = list.filter((t) => String(t.eventId) === String(eventId));
}
if (selectedUserId) {
list = list.filter(
(t) => String(t.user?.id || t.userId) === String(selectedUserId)
);
}
if (selectedVariantId) {
list = list.filter(
(t) => String(t.registrationOption?.variant?.id || "") === String(selectedVariantId)
);
}
return list;
}, [tickets, eventId, selectedUserId, selectedVariantId]);
// Printing helpers
const buildTicketHtmlCard = (t: any) => {
const eventTitle = t.event?.title || t.eventId || "Event";
const eventDate = t.event?.startDate ? new Date(t.event.startDate) : null;
const optBase = t.registrationOption?.eventOption?.name || "Ticket";
const optVariant = t.registrationOption?.variant?.name;
const type = optVariant ? `${optBase}${optVariant}` : optBase;
const qty = t.quantity || 1;
const holder = t.user?.name || t.userId || "";
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=140x140&data=${encodeURIComponent(
t.qrCode || t.id
)}`;
const dateStr = eventDate ? formatDate(eventDate) : "";
return `
<div class="ticket">
<div class="evt">${eventTitle}</div>
${dateStr ? `<div class="date">${dateStr}</div>` : ""}
<div class="type">${type}</div>
<div class="qty">Qty: <strong>${qty}</strong></div>
<div class="qrwrap"><img src="${qrSrc}" alt="QR" /></div>
<div class="meta">ID: ${t.id}</div>
${holder ? `<div class="meta">${holder}</div>` : ""}
</div>
`;
};
const openPrintWindow = (pagesHtml: string) => {
const w = window.open("", "_blank");
if (!w) return;
w.document.write(`<!doctype html><html><head><title>Tickets</title>
<style>
@page { size: A4; margin: 10mm; }
* { box-sizing: border-box; }
body { font-family: Arial, Helvetica, sans-serif; margin: 0; padding: 0; }
.page {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(4, 1fr);
height: 277mm;
gap: 6mm;
page-break-after: always;
break-after: page;
}
.page:last-child { page-break-after: avoid; break-after: avoid; }
.ticket { border: 1px solid #000; padding: 5mm; display: flex; flex-direction: column; justify-content: space-between; overflow: hidden; }
.evt { font-weight: bold; font-size: 14px; }
.type { font-size: 12px; margin-top: 2mm; }
.qty { font-size: 11px; margin-top: 1mm; }
.date { font-size: 10px; color: #555; margin-top: 1mm; }
.qrwrap { display: flex; justify-content: center; margin: 2mm 0; }
.qrwrap img { width: 30mm; height: 30mm; }
.meta { font-size: 10px; text-align: center; }
</style>
</head><body>
${pagesHtml}
<script>
(function(){
function printWhenReady(){
var imgs = Array.prototype.slice.call(document.images);
if(imgs.length === 0){ window.print(); return; }
var remaining = imgs.length;
function done(){ remaining--; if(remaining <= 0){ setTimeout(function(){ window.print(); }, 100); } }
imgs.forEach(function(img){
if (img.complete) { done(); }
else {
img.addEventListener('load', done, { once: true });
img.addEventListener('error', done, { once: true });
}
});
}
if (document.readyState === 'complete') printWhenReady();
else window.addEventListener('load', printWhenReady);
})();
</script>
</body></html>`);
w.document.close();
w.focus();
};
const printTickets = (ticketList: any[]) => {
if (!ticketList || ticketList.length === 0) return;
// Chunk into pages of 8 (2 columns × 4 rows per A4 sheet)
const PAGE_SIZE = 8;
const pages: string[] = [];
for (let i = 0; i < ticketList.length; i += PAGE_SIZE) {
const chunk = ticketList.slice(i, i + PAGE_SIZE);
pages.push(`<div class="page">${chunk.map(buildTicketHtmlCard).join("")}</div>`);
}
openPrintWindow(pages.join(""));
};
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4 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 Tickets</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")}
>
Back
</button>
</div>
<p className="text-sm text-gray-600 mb-4">
View and print tickets for an event.
</p>
{!canView && (
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
You need staff, supervisor, or admin access to view this page.
</div>
)}
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-2 mb-4">
<label className="text-sm text-gray-700 shrink-0">Event</label>
<select
className="border rounded px-3 py-2 w-full sm:w-64 max-w-full"
value={eventId}
onChange={(e) => {
const v = e.target.value;
setEventId(v);
setSelectedUserId("");
router.replace(
`?eventId=${encodeURIComponent(v)}${
selectedUserId
? `&userId=${encodeURIComponent(selectedUserId)}`
: ""
}`
);
}}
>
<option value="">All events</option>
{events.map((ev) => (
<option key={ev.id} value={ev.id}>
{ev.title}
</option>
))}
</select>
<label className="text-sm text-gray-700 sm:ml-4 shrink-0">User</label>
<select
className="border rounded px-3 py-2 w-full sm:w-64 max-w-full"
value={selectedUserId}
onChange={(e) => {
const v = e.target.value;
setSelectedUserId(v);
router.replace(
`?eventId=${encodeURIComponent(eventId || "")}${
v ? `&userId=${encodeURIComponent(v)}` : ""
}`
);
}}
disabled={usersList.length === 0}
>
<option value="">All users</option>
{usersList.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
{variantsList.length > 0 && (
<>
<label className="text-sm text-gray-700 sm:ml-4 shrink-0">Variant</label>
<select
className="border rounded px-3 py-2 w-full sm:w-48 max-w-full"
value={selectedVariantId}
onChange={(e) => setSelectedVariantId(e.target.value)}
>
<option value="">All variants</option>
{variantsList.map((v) => (
<option key={v.id} value={v.id}>{v.name}</option>
))}
</select>
</>
)}
{filteredTickets.length > 0 && (
<div className="flex gap-2">
<button
onClick={() => printTickets(filteredTickets)}
className="px-3 py-2 border rounded"
>
Print all
</button>
<button
onClick={() =>
printTickets(filteredTickets.filter((t) => !t.isUsed))
}
className="px-3 py-2 bg-brand-600 hover:bg-brand-700 text-white rounded"
>
Print unused
</button>
</div>
)}
</div>
{loading && <div>Loading...</div>}
{error && <div className="text-red-600 text-sm">{error}</div>}
{filteredTickets.length > 0 && (
<div className="mb-3 text-sm text-gray-700">
<span className="mr-3">
Total tickets: {filteredTickets.length}
</span>
<span className="mr-3">
Used: {filteredTickets.filter((t) => t.isUsed).length}
</span>
<span>
Unused: {filteredTickets.filter((t) => !t.isUsed).length}
</span>
</div>
)}
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
{filteredTickets.map((t) => {
const eventDate = t.event?.startDate
? new Date(t.event.startDate)
: null;
const optBase = t.registrationOption?.eventOption?.name || "Ticket";
const optVariant = t.registrationOption?.variant?.name;
const type = optVariant ? `${optBase}${optVariant}` : optBase;
return (
<div
key={t.id}
className="border rounded-xl p-3 space-y-2 bg-white shadow-sm"
>
<div className="flex items-start justify-between">
<div className="font-medium">
{t.event?.title || t.eventId}
</div>
{eventDate && (
<span className="text-xs text-gray-500">
{formatDate(eventDate)}
</span>
)}
</div>
<div className="text-sm text-gray-700">{type}</div>
<div className="flex justify-center">
<QrImage value={t.qrCode || t.id} size={120} className="w-28 h-28" />
</div>
<div className="text-center text-xs text-gray-500">
#{String(t.id).slice(0, 8)} {" "}
{t.isUsed ? "Used" : "Unused"}
</div>
</div>
);
})}
</div>
</div>
);
}
export default function EventTicketsPage() {
return (
<Suspense fallback={<div className="p-6">Loading...</div>}>
<EventTicketsContent />
</Suspense>
);
}