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

494 lines
22 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, { useEffect, useMemo, useRef, useState } from "react";
import { QRScanner, QRScannerHandle } from "@/components/qr/QRScanner";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useRouter } from "next/navigation";
import { QrCode } from "lucide-react";
export default function TicketScanningPage() {
const router = useRouter();
const scannerRef = useRef<QRScannerHandle>(null);
const [lastResult, setLastResult] = useState<string | null>(null);
const [scanInfo, setScanInfo] = useDismissingState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
const [alreadyUsedModal, setAlreadyUsedModal] = useState<{ message: string; ticket?: any } | null>(null);
const [errorModal, setErrorModal] = useState<string | null>(null);
const { token, user } = useAuth();
const [recentScans, setRecentScans] = useState<any[]>([]);
const [stats, setStats] = useState<any | null>(null);
const [loadingStats, setLoadingStats] = useState<boolean>(false);
const [successModal, setSuccessModal] = useState<{
optionName: string;
ticketId: string;
eventTitle: string;
qtyRedeemed: number;
remaining: number;
total: number;
} | null>(null);
// Confirm step state
const [confirmModal, setConfirmModal] = useState<{
qrCode: string;
ticket: any;
remaining: number;
} | null>(null);
const [confirmQty, setConfirmQty] = useState(1);
const [confirmQtyRaw, setConfirmQtyRaw] = useState("1");
// Event selection state
const [allEvents, setAllEvents] = useState<any[]>([]);
const [includePastEvents, setIncludePastEvents] = useState(false);
const [selectedEventId, setSelectedEventId] = useState<string>("all");
const [loadingEvents, setLoadingEvents] = useState<boolean>(false);
const [sections, setSections] = useState<any[]>([]);
const [selectedSectionId, setSelectedSectionId] = useState<string>("all");
const [loadingSections, setLoadingSections] = useState(false);
// All events from API (including past); frontend filters to 12h window or all past
useEffect(() => {
(async () => {
try {
if (!token) return;
setLoadingEvents(true);
const evs = await apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token });
const sorted = (evs || []).sort((a, b) => new Date(a.endDate).getTime() - new Date(b.endDate).getTime());
setAllEvents(sorted);
} catch (e: any) {
// non-fatal
} finally {
setLoadingEvents(false);
}
})();
}, [token]);
const events = useMemo(() => {
if (includePastEvents) return allEvents;
const cutoff = Date.now() - 12 * 60 * 60 * 1000;
return allEvents.filter(ev => {
const t = new Date(ev.endDate).getTime();
return !isNaN(t) && t >= cutoff;
});
}, [allEvents, includePastEvents]);
useEffect(() => {
(async () => {
if (!token || !selectedEventId || selectedEventId === "all") {
setSections([]);
setSelectedSectionId("all");
return;
}
try {
setLoadingSections(true);
const list = await apiFetch<any[]>(`/api/sections?eventId=${selectedEventId}`, { authToken: token });
setSections(Array.isArray(list) ? list : []);
setSelectedSectionId("all");
} catch {
setSections([]);
} finally {
setLoadingSections(false);
}
})();
}, [selectedEventId, token]);
// Bug fix: stop the scanner when the event or section filter changes so stale scan
// context is never used. The user must press "Scan Ticket" again to resume.
const isFirstFilterRender = useRef(true);
useEffect(() => {
if (isFirstFilterRender.current) { isFirstFilterRender.current = false; return; }
scannerRef.current?.stop();
}, [selectedEventId, selectedSectionId]);
async function handleResult(text: string) {
setLastResult(text);
setError(null);
setScanInfo(null);
try {
if (!token) {
setErrorModal("Please login as staff to scan tickets.");
return;
}
// Always preview first to validate the ticket before showing confirm step
// Uses the lightweight scan-preview endpoint (only fetches fields needed for the confirm modal)
let preview: any;
try {
preview = await apiFetch<any>(`/api/tickets/scan-preview/${encodeURIComponent(text)}`, { authToken: token });
} catch (pe: any) {
setErrorModal(pe?.message || 'Failed to validate ticket');
return;
}
// Validate event
if (selectedEventId && selectedEventId !== "all") {
const ticketEventId = preview?.event?.id || preview?.eventId;
if (ticketEventId && ticketEventId !== selectedEventId) {
const eventTitle = preview?.event?.title || 'Event';
setErrorModal(`This ticket is for a different event: ${eventTitle}`);
return;
}
// Validate section
if (selectedSectionId !== "all") {
const ticketOptionId = preview?.registrationOption?.eventOption?.id || preview?.eventOptionId;
if (!ticketOptionId) {
setErrorModal("Unable to determine ticket type");
return;
}
const section = sections.find(s => s.id === selectedSectionId);
const allowedOptionIds = (section?.allowedOptions || []).map((o: any) => o.eventOptionId);
if (!allowedOptionIds.includes(ticketOptionId)) {
setErrorModal("This ticket is not valid for this section");
return;
}
}
}
// Compute remaining qty
const ticketQty = preview?.quantity || 1;
const totalRedeemed = (preview?.usages || []).reduce((s: number, u: any) => s + (u.quantityRedeemed || 1), 0);
const remaining = ticketQty - totalRedeemed;
if (remaining <= 0) {
setAlreadyUsedModal({ message: 'Ticket has already been fully used', ticket: preview });
return;
}
// Show confirm step
setConfirmQty(remaining);
setConfirmQtyRaw(String(remaining));
setConfirmModal({ qrCode: text, ticket: preview, remaining });
} catch (e: any) {
setErrorModal(e?.message || "Failed to scan ticket");
}
}
async function commitScan() {
if (!confirmModal || !token) return;
// Capture everything before clearing modal state
const { qrCode, ticket, remaining } = confirmModal;
const qty = confirmQty;
const baseName = ticket?.registrationOption?.eventOption?.name || "Ticket";
const variantName = ticket?.registrationOption?.variant?.name;
const optionName = variantName ? `${baseName}${variantName}` : baseName;
const eventTitle = ticket?.event?.title || 'Event';
const idPart = ticket?.id ? String(ticket.id).slice(0, 6) : '';
const newRemaining = remaining - qty;
// Show success immediately — we already have all the data from the preview step.
// The actual write happens in the background so the user sees an instant response.
setConfirmModal(null);
setScanInfo(`Ticket #${idPart}${optionName}${eventTitle}`);
setSuccessModal({
optionName,
ticketId: idPart,
eventTitle,
qtyRedeemed: qty,
remaining: newRemaining,
total: ticket?.quantity || 1,
});
// Background write — reverse the optimistic update on failure
try {
const url = `/api/tickets/scan/${encodeURIComponent(qrCode)}${
selectedEventId && selectedEventId !== "all" ? `?eventId=${encodeURIComponent(selectedEventId)}` : ''
}`;
await apiFetch<any>(url, {
method: "POST",
authToken: token,
body: { qty },
});
refreshStatsAndRecent();
} catch (e: any) {
setSuccessModal(null);
setScanInfo(null);
const raw = e?.message || "Failed to scan ticket";
try {
const parsed = JSON.parse(raw);
const msg: string | undefined = parsed?.message;
if (msg && /already\s*(been)?\s*used/i.test(msg)) {
setAlreadyUsedModal({ message: msg, ticket: parsed?.ticket });
return;
}
setErrorModal(msg || raw);
} catch {
setErrorModal(raw);
}
}
}
const loadRecent = async () => {
if (!token) return;
try {
const list = await apiFetch<any[]>(`/api/tickets/scans/recent?limit=10`, { authToken: token });
setRecentScans(list);
} catch {}
};
const loadStats = async () => {
if (!token) return;
try {
setLoadingStats(true);
const s = await apiFetch<any>(`/api/tickets/scans/stats`, { authToken: token });
setStats(s);
} catch {} finally {
setLoadingStats(false);
}
};
const refreshStatsAndRecent = () => { loadRecent(); loadStats(); };
useEffect(() => {
refreshStatsAndRecent();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
return (
<div className="max-w-6xl mx-auto w-full p-4">
<div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<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">
<QrCode className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">Ticket Scanning</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">
Use the button to start/stop scanning. The back camera will be used when available.
</p>
{/* Event filter */}
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-2 mb-3">
<label className="text-sm text-gray-700 shrink-0">Event</label>
<select
className="border rounded px-3 py-2 w-full sm:w-56 max-w-full"
value={selectedEventId}
onChange={(e) => setSelectedEventId(e.target.value)}
>
<option value="all">All events</option>
{events.map((ev) => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
<label className="flex items-center gap-1.5 text-sm text-gray-600 cursor-pointer">
<input type="checkbox" checked={includePastEvents} onChange={e => setIncludePastEvents(e.target.checked)} />
Include past events
</label>
{loadingEvents && <span className="text-xs text-gray-500">Loading events</span>}
</div>
{sections.length > 0 && (
<div className="flex flex-col sm:flex-row sm:items-center gap-2 mb-3">
<label className="text-sm text-gray-700 shrink-0">Section</label>
<select
className="border rounded px-3 py-2 w-full sm:w-56 max-w-full"
value={selectedSectionId}
onChange={(e) => setSelectedSectionId(e.target.value)}
>
<option value="all">All sections</option>
{sections.map(section => (
<option key={section.id} value={section.id}>{section.name}</option>
))}
</select>
{loadingSections && <span className="text-xs text-gray-500">Loading sections</span>}
</div>
)}
<QRScanner
ref={scannerRef}
onResult={handleResult}
paused={!!(confirmModal || alreadyUsedModal || errorModal || successModal)}
/>
{scanInfo && (
<div className="mt-2 p-3 border rounded bg-green-50 text-green-800 text-sm">{scanInfo}</div>
)}
{error && (
<div className="mt-2 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>
)}
</div>
<div>
<h2 className="text-xl font-semibold mb-3">Scanner Stats</h2>
{loadingStats && <div className="text-sm text-gray-500 mb-2">Loading stats</div>}
{stats && (
<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">{stats.totalToday}</div>
</div>
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">My scans</div>
<div className="text-lg font-semibold">{stats.myToday}</div>
</div>
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Last hour</div>
<div className="text-lg font-semibold">{stats.lastHour}</div>
</div>
</div>
)}
{stats?.byStaff?.length > 0 && (
<div className="mb-4">
<div className="text-sm font-medium mb-1">Today by staff</div>
<ul className="text-sm text-gray-700 space-y-1">
{stats.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>
)}
<h3 className="text-lg font-semibold mb-2">Last 10 scans</h3>
<ul className="text-sm space-y-2 max-h-80 overflow-auto pr-2">
{recentScans.map((u: any) => (
<li key={u.id} className="border rounded p-2 bg-white">
<div className="flex justify-between">
<div className="font-medium">{u.ticket?.event?.title || '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'}{u.ticket?.registrationOption?.variant?.name ? ` — ${u.ticket.registrationOption.variant.name}` : ''}
{u.quantityRedeemed && u.quantityRedeemed > 1 ? ` ×${u.quantityRedeemed}` : ''}
{' — '}#{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>
{/* ── Confirm scan modal ───────────────────────────────────────────── */}
{confirmModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-white rounded-xl shadow-lg max-w-md w-full p-6">
<h3 className="text-lg font-semibold mb-1 text-brand-700">Confirm Scan</h3>
<p className="text-sm text-gray-500 mb-4">Review the ticket details before confirming.</p>
<div className="bg-gray-50 border rounded-lg p-4 mb-4 space-y-1 text-sm">
<div><span className="font-medium">Ticket type:</span> {confirmModal.ticket?.registrationOption?.eventOption?.name || 'Ticket'}{confirmModal.ticket?.registrationOption?.variant?.name ? ` — ${confirmModal.ticket.registrationOption.variant.name}` : ''}</div>
<div><span className="font-medium">Event:</span> {confirmModal.ticket?.event?.title || '—'}</div>
<div><span className="font-medium">Holder:</span> {confirmModal.ticket?.user?.name || confirmModal.ticket?.registrationOption?.registration?.user?.name || '—'}</div>
<div><span className="font-medium">Qty on ticket:</span> {confirmModal.ticket?.quantity || 1}</div>
<div><span className="font-medium">Remaining:</span> <span className={confirmModal.remaining < (confirmModal.ticket?.quantity || 1) ? 'text-amber-600 font-semibold' : 'text-green-700 font-semibold'}>{confirmModal.remaining}</span></div>
</div>
{(confirmModal.ticket?.quantity || 1) > 1 && (
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">Qty to redeem (max {confirmModal.remaining})</label>
<input
type="text"
inputMode="numeric"
min={1}
max={confirmModal.remaining}
className="w-24 border rounded px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
value={confirmQtyRaw}
onChange={e => setConfirmQtyRaw(e.target.value)}
onBlur={() => {
const parsed = parseInt(confirmQtyRaw, 10);
const clamped = isNaN(parsed) ? 1 : Math.min(confirmModal.remaining, Math.max(1, parsed));
setConfirmQty(clamped);
setConfirmQtyRaw(String(clamped));
}}
/>
</div>
)}
<div className="flex gap-3">
<button
onClick={() => setConfirmModal(null)}
className="flex-1 px-4 py-2 rounded-lg border text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={commitScan}
className="flex-1 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-semibold hover:bg-brand-700"
>
Confirm Scan
</button>
</div>
</div>
</div>
)}
{/* ── Already used modal ───────────────────────────────────────────── */}
{alreadyUsedModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-white rounded shadow-lg max-w-md w-full p-5">
<h3 className="text-lg font-semibold mb-2 text-red-600">Ticket Already Scanned</h3>
<p className="text-sm text-gray-700 mb-3">{alreadyUsedModal.message}</p>
{alreadyUsedModal.ticket && (
<div className="text-xs text-gray-600 bg-gray-50 p-3 rounded border">
<div><span className="font-medium">Ticket ID:</span> {alreadyUsedModal.ticket.id}</div>
{alreadyUsedModal.ticket.event?.title && (
<div><span className="font-medium">Event:</span> {alreadyUsedModal.ticket.event.title}</div>
)}
{Array.isArray(alreadyUsedModal.ticket.usages) && alreadyUsedModal.ticket.usages.length > 0 && (
<div className="mt-2">
<div className="font-medium">Usages:</div>
<ul className="list-disc ml-5 mt-1 max-h-32 overflow-auto">
{alreadyUsedModal.ticket.usages.map((u: any) => (
<li key={u.id}>{new Date(u.scannedAt).toLocaleString()}{u.quantityRedeemed > 1 ? ` (×${u.quantityRedeemed})` : ''}</li>
))}
</ul>
</div>
)}
</div>
)}
<div className="mt-4 flex justify-end">
<button className="px-4 py-2 rounded bg-red-600 text-white" onClick={() => setAlreadyUsedModal(null)}>OK</button>
</div>
</div>
</div>
)}
{/* ── Error modal ──────────────────────────────────────────────────── */}
{errorModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-white rounded shadow-lg max-w-md w-full p-5">
<h3 className="text-lg font-semibold mb-2 text-red-600">Scan Error</h3>
<p className="text-sm text-gray-700 mb-3">{errorModal}</p>
<div className="mt-4 flex justify-end">
<button className="px-4 py-2 rounded bg-red-600 text-white" onClick={() => setErrorModal(null)}>OK</button>
</div>
</div>
</div>
)}
{/* ── Success modal ────────────────────────────────────────────────── */}
{successModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-white rounded-xl shadow-lg max-w-md w-full p-6">
<h3 className="text-lg font-semibold mb-4 text-green-600">Scan Successful</h3>
<div className="text-3xl font-bold text-center text-gray-900 mb-2">
{successModal.optionName}
</div>
<div className="text-sm text-center text-gray-600 mb-3">
#{successModal.ticketId} {successModal.eventTitle}
</div>
{successModal.total > 1 && (
<div className={`text-center text-sm font-medium mb-4 ${successModal.remaining > 0 ? 'text-amber-600' : 'text-green-700'}`}>
{successModal.qtyRedeemed} redeemed · {successModal.remaining} remaining of {successModal.total}
</div>
)}
<div className="flex justify-center">
<button className="px-6 py-2 rounded bg-green-600 text-white" onClick={() => setSuccessModal(null)}>OK</button>
</div>
</div>
</div>
)}
</div>
);
}