The emailed/WhatsApp ticket PDF and the staff and at-the-door print views now match the user dashboard's printed tickets: rounded cards with a light border and soft shadow. Printing goes through a shared helper in lib/ticketPrint.ts, which also escapes names in the print HTML. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
319 lines
11 KiB
TypeScript
319 lines
11 KiB
TypeScript
"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 { printTicketCards } from "@/lib/ticketPrint";
|
|
import { QrImage } from "@/components/shared/QrImage";
|
|
import { Ticket } from "lucide-react";
|
|
import { errorMessage } from "@/lib/utils";
|
|
import type { Event, Ticket as TicketRecord } from "@/types";
|
|
import { BackLink } from "@/components/shared/BackLink";
|
|
|
|
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<Event[]>([]);
|
|
const [eventId, setEventId] = useState<string>(paramEventId);
|
|
const [tickets, setTickets] = useState<TicketRecord[]>([]);
|
|
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<Event[]>("/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);
|
|
} catch (e) {
|
|
setError(errorMessage(e) || "Failed to load events");
|
|
}
|
|
})();
|
|
}, [token, setError]);
|
|
|
|
// No event in the URL: default to the first upcoming one once the list arrives.
|
|
useEffect(() => {
|
|
if (paramEventId || events.length === 0) return;
|
|
setEventId(events[0].id);
|
|
router.replace(
|
|
`?eventId=${encodeURIComponent(events[0].id)}${
|
|
selectedUserId ? `&userId=${encodeURIComponent(selectedUserId)}` : ""
|
|
}`
|
|
);
|
|
}, [events, paramEventId, router, selectedUserId]);
|
|
|
|
// Sync params → state (setting the value it already has is a no-op)
|
|
useEffect(() => {
|
|
if (paramEventId) setEventId(paramEventId);
|
|
if (paramUserId) setSelectedUserId(paramUserId);
|
|
}, [paramEventId, paramUserId]);
|
|
|
|
// Load tickets when event changes
|
|
useEffect(() => {
|
|
(async () => {
|
|
if (!token || !eventId) return;
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const list = await apiFetch<TicketRecord[]>(
|
|
`/api/tickets/event/${encodeURIComponent(eventId)}`,
|
|
{ authToken: token }
|
|
);
|
|
setTickets(list);
|
|
} catch (e) {
|
|
setError(errorMessage(e) || "Failed to load tickets");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
})();
|
|
}, [token, eventId, setError]);
|
|
|
|
// 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]);
|
|
|
|
const printTickets = (ticketList: TicketRecord[]) => printTicketCards(ticketList, (t) => t.user?.name);
|
|
|
|
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>
|
|
<div>
|
|
<BackLink href="/dashboard" />
|
|
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Event Tickets</h1>
|
|
</div>
|
|
</div>
|
|
</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>
|
|
);
|
|
}
|