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>
142 lines
6.0 KiB
TypeScript
142 lines
6.0 KiB
TypeScript
"use client";
|
|
|
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
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();
|
|
const router = useRouter();
|
|
|
|
// Access control (staff/supervisor/admin)
|
|
const canView = useMemo(() => {
|
|
const role = user?.role;
|
|
return role === "admin" || role === "supervisor" || role === "staff";
|
|
}, [user]);
|
|
|
|
useEffect(() => {
|
|
if (loading) return;
|
|
if (!user) router.replace("/login");
|
|
}, [user, loading, router]);
|
|
|
|
// Stats and recent scans for quick overview — both come from one endpoint
|
|
// (/api/stats/staff) instead of two separate calls. useStableState skips re-renders when
|
|
// a poll returns identical data, and hasLoadedOnce below means "Refreshing…" only shows
|
|
// on the very first load — together these stop the stats panel from flickering.
|
|
const [stats, setStats] = useStableState<any | null>(null);
|
|
const [recentScans, setRecentScans] = useStableState<any[]>([]);
|
|
const [loadingStats, setLoadingStats] = useState(false);
|
|
const hasLoadedOnce = useRef(false);
|
|
|
|
const loadStats = async () => {
|
|
if (!token) return;
|
|
const isFirstLoad = !hasLoadedOnce.current;
|
|
try {
|
|
if (isFirstLoad) setLoadingStats(true);
|
|
const data = await apiFetch<any>("/api/stats/staff", { authToken: token });
|
|
setStats(data.scanStats);
|
|
setRecentScans(Array.isArray(data.recentScans) ? data.recentScans : []);
|
|
} catch (e) {
|
|
// ignore
|
|
} finally {
|
|
hasLoadedOnce.current = true;
|
|
if (isFirstLoad) setLoadingStats(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!token) return;
|
|
loadStats();
|
|
}, [token]);
|
|
|
|
// Poll every 10s while the tab is visible; pause in the background and refetch
|
|
// immediately on return instead of leaving stale numbers up.
|
|
useVisiblePolling(() => {
|
|
if (!token) return;
|
|
loadStats();
|
|
}, 10000, !!token);
|
|
|
|
return (
|
|
<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 && (
|
|
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
|
|
You need staff, supervisor, or admin access to use staff tools.
|
|
</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">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h2 className="text-lg font-semibold">Recent scans</h2>
|
|
{loadingStats && <span className="text-xs text-gray-500">Refreshing…</span>}
|
|
</div>
|
|
<ul className="text-sm space-y-2 max-h-96 overflow-auto pr-2">
|
|
{recentScans.map((u: any) => (
|
|
<li key={u.id} className="border rounded p-2">
|
|
<div className="flex justify-between">
|
|
<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-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>
|
|
|
|
<div>
|
|
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
|
<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>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|