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>
This commit is contained in:
@@ -7,6 +7,7 @@ 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();
|
||||
@@ -229,10 +230,15 @@ function EventTicketsContent() {
|
||||
|
||||
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>
|
||||
<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 bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm"
|
||||
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
|
||||
@@ -325,7 +331,7 @@ function EventTicketsContent() {
|
||||
onClick={() =>
|
||||
printTickets(filteredTickets.filter((t) => !t.isUsed))
|
||||
}
|
||||
className="px-3 py-2 bg-blue-600 text-white rounded"
|
||||
className="px-3 py-2 bg-brand-600 hover:bg-brand-700 text-white rounded"
|
||||
>
|
||||
Print unused
|
||||
</button>
|
||||
|
||||
@@ -6,6 +6,14 @@ import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useStableState } from "@/hooks/useStableState";
|
||||
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
|
||||
import { QrCode, Ticket, Activity, UserCheck, Clock } from "lucide-react";
|
||||
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||
|
||||
const QUICK_ACTIONS = [
|
||||
{ href: "/dashboard/staff/ticket-scanning", label: "Scan tickets", description: "Use your device camera to validate tickets", icon: QrCode },
|
||||
{ href: "/dashboard/staff/event-tickets", label: "Event tickets & printing", description: "Browse event tickets and print lists", icon: Ticket },
|
||||
] as const;
|
||||
|
||||
export default function StaffDashboardPage() {
|
||||
const { user, loading, token } = useAuth();
|
||||
@@ -60,13 +68,10 @@ export default function StaffDashboardPage() {
|
||||
}, 10000, !!token);
|
||||
|
||||
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">Staff Dashboard{user ? ` — ${user.name}` : ""}</h1>
|
||||
<div className="hidden sm:flex gap-2">
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/ticket-scanning")}>Open scanner</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/event-tickets")}>Event tickets</button>
|
||||
</div>
|
||||
<div className="max-w-6xl mx-auto w-full">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Welcome back{user ? `, ${user.name}` : ""} 👋</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Here's today's scanning activity.</p>
|
||||
</div>
|
||||
|
||||
{!canView && (
|
||||
@@ -75,20 +80,23 @@ export default function StaffDashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StatCardRow>
|
||||
<StatCard icon={Activity} label="Scans today" value={stats ? stats.totalToday : "—"} tone="brand" />
|
||||
<StatCard icon={UserCheck} label="My scans today" value={stats ? stats.myToday : "—"} tone="blue" />
|
||||
<StatCard icon={Clock} label="Last hour" value={stats ? stats.lastHour : "—"} tone="amber" />
|
||||
</StatCardRow>
|
||||
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm my-6">
|
||||
<div className="text-lg font-semibold mb-3">Quick actions</div>
|
||||
<QuickActionGrid>
|
||||
{QUICK_ACTIONS.map(a => (
|
||||
<QuickActionTile key={a.href} icon={a.icon} title={a.label} description={a.description} href={a.href} />
|
||||
))}
|
||||
</QuickActionGrid>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
|
||||
<div className="text-lg font-semibold mb-2">Quick actions</div>
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/ticket-scanning")}>Scan tickets
|
||||
<div className="text-xs text-white/90">Use your device camera to validate tickets</div>
|
||||
</button>
|
||||
<button className="rounded-lg p-3 text-left bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/event-tickets")}>Event tickets & printing
|
||||
<div className="text-xs text-white/90">Browse event tickets and print lists</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold">Recent scans</h2>
|
||||
@@ -101,7 +109,7 @@ export default function StaffDashboardPage() {
|
||||
<div className="font-medium">{u.ticket?.event?.title || u.ticket?.eventId || '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'} — #{String(u.ticket?.id || '').slice(0,8)}</div>
|
||||
<div className="text-xs text-gray-600">{u.ticket?.registrationOption?.eventOption?.name || 'Ticket'} — #{String(u.ticket?.id || '').slice(0, 8)}</div>
|
||||
<div className="text-xs text-gray-500">Scanned by: {u.scannedBy?.name || u.scannedById}</div>
|
||||
</li>
|
||||
))}
|
||||
@@ -112,37 +120,18 @@ export default function StaffDashboardPage() {
|
||||
|
||||
<div>
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<h2 className="text-lg 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-1">
|
||||
<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>
|
||||
<h2 className="text-lg font-semibold mb-3">Today by staff</h2>
|
||||
{stats?.byStaff?.length > 0 ? (
|
||||
<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 className="text-sm text-gray-500">No scans recorded yet today.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ 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();
|
||||
@@ -246,9 +247,14 @@ export default function TicketScanningPage() {
|
||||
<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">
|
||||
<h1 className="text-2xl font-semibold">Ticket Scanning</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 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.
|
||||
@@ -363,7 +369,7 @@ export default function TicketScanningPage() {
|
||||
{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-indigo-700">Confirm Scan</h3>
|
||||
<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">
|
||||
@@ -382,7 +388,7 @@ export default function TicketScanningPage() {
|
||||
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-indigo-400"
|
||||
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={() => {
|
||||
@@ -404,7 +410,7 @@ export default function TicketScanningPage() {
|
||||
</button>
|
||||
<button
|
||||
onClick={commitScan}
|
||||
className="flex-1 px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-semibold hover:bg-indigo-700"
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user