Files
hope-events/frontend/src/app/dashboard/staff/event-tickets/page.tsx
T
joshuaandClaude Sonnet 5 f3e6525467 Add registration status badges and auto-dismissing dashboard messages
Two consistency fixes requested after the payment-method work:

1. Registration status (pending/confirmed/partial_paid/paid/cancelled)
   was printed as a raw string on the user dashboard. Added
   RegistrationStatusBadge mirroring the existing EventStatusBadge
   pattern, using the same status colors already established on
   dashboard/admin/registrations.

2. Inline success/error banners across dashboard pages persisted
   indefinitely. Added a shared useDismissingState hook (drop-in
   useState replacement that auto-clears a truthy value after 7s,
   resetting the timer on each update) and swapped it in across ~24
   dashboard files. Excluded: message-only modal dialogs (ticket-
   scanning's success/error confirmations) and two states that mix
   live form-validation feedback with async results inside actively-
   open forms (the registration-edit modal's editError, the event
   create/edit modal's error) - those keep persisting until the user
   acts, since auto-hiding a "fix this field" message mid-edit would
   be a regression. Also fixed at-the-door's existing bespoke
   auto-dismiss timers (10s/15s, one mislabeled as "5s") to the same
   consistent 7s, and removed admin/settings' manual x dismiss button
   in favor of the same auto-only behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 10:17:53 +02:00

399 lines
14 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";
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">
<h1 className="text-2xl font-semibold">Event Tickets</h1>
<button
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm"
onClick={() => router.push("/dashboard")}
>
Back
</button>
</div>
<p className="text-sm text-gray-600 mb-4">
View and print tickets for an event.
</p>
{!canView && (
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
You need staff, supervisor, or admin access to view this page.
</div>
)}
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-2 mb-4">
<label className="text-sm text-gray-700 shrink-0">Event</label>
<select
className="border rounded px-3 py-2 w-full sm:w-64 max-w-full"
value={eventId}
onChange={(e) => {
const v = e.target.value;
setEventId(v);
setSelectedUserId("");
router.replace(
`?eventId=${encodeURIComponent(v)}${
selectedUserId
? `&userId=${encodeURIComponent(selectedUserId)}`
: ""
}`
);
}}
>
<option value="">All events</option>
{events.map((ev) => (
<option key={ev.id} value={ev.id}>
{ev.title}
</option>
))}
</select>
<label className="text-sm text-gray-700 sm:ml-4 shrink-0">User</label>
<select
className="border rounded px-3 py-2 w-full sm:w-64 max-w-full"
value={selectedUserId}
onChange={(e) => {
const v = e.target.value;
setSelectedUserId(v);
router.replace(
`?eventId=${encodeURIComponent(eventId || "")}${
v ? `&userId=${encodeURIComponent(v)}` : ""
}`
);
}}
disabled={usersList.length === 0}
>
<option value="">All users</option>
{usersList.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
{variantsList.length > 0 && (
<>
<label className="text-sm text-gray-700 sm:ml-4 shrink-0">Variant</label>
<select
className="border rounded px-3 py-2 w-full sm:w-48 max-w-full"
value={selectedVariantId}
onChange={(e) => setSelectedVariantId(e.target.value)}
>
<option value="">All variants</option>
{variantsList.map((v) => (
<option key={v.id} value={v.id}>{v.name}</option>
))}
</select>
</>
)}
{filteredTickets.length > 0 && (
<div className="flex gap-2">
<button
onClick={() => printTickets(filteredTickets)}
className="px-3 py-2 border rounded"
>
Print all
</button>
<button
onClick={() =>
printTickets(filteredTickets.filter((t) => !t.isUsed))
}
className="px-3 py-2 bg-blue-600 text-white rounded"
>
Print unused
</button>
</div>
)}
</div>
{loading && <div>Loading...</div>}
{error && <div className="text-red-600 text-sm">{error}</div>}
{filteredTickets.length > 0 && (
<div className="mb-3 text-sm text-gray-700">
<span className="mr-3">
Total tickets: {filteredTickets.length}
</span>
<span className="mr-3">
Used: {filteredTickets.filter((t) => t.isUsed).length}
</span>
<span>
Unused: {filteredTickets.filter((t) => !t.isUsed).length}
</span>
</div>
)}
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
{filteredTickets.map((t) => {
const eventDate = t.event?.startDate
? new Date(t.event.startDate)
: null;
const optBase = t.registrationOption?.eventOption?.name || "Ticket";
const optVariant = t.registrationOption?.variant?.name;
const type = optVariant ? `${optBase}${optVariant}` : optBase;
return (
<div
key={t.id}
className="border rounded-xl p-3 space-y-2 bg-white shadow-sm"
>
<div className="flex items-start justify-between">
<div className="font-medium">
{t.event?.title || t.eventId}
</div>
{eventDate && (
<span className="text-xs text-gray-500">
{formatDate(eventDate)}
</span>
)}
</div>
<div className="text-sm text-gray-700">{type}</div>
<div className="flex justify-center">
<QrImage value={t.qrCode || t.id} size={120} className="w-28 h-28" />
</div>
<div className="text-center text-xs text-gray-500">
#{String(t.id).slice(0, 8)} {" "}
{t.isUsed ? "Used" : "Unused"}
</div>
</div>
);
})}
</div>
</div>
);
}
export default function EventTicketsPage() {
return (
<Suspense fallback={<div className="p-6">Loading...</div>}>
<EventTicketsContent />
</Suspense>
);
}