- Add matching lucide-react icons to tab/mode switcher buttons on payments, at-the-door, manual, email-attendees, whatsapp-attendees, and admin cashup pages, mirroring icons already used in their help menus - Fix navbar Logout button sitting lower than other nav links (missing border/padding classes that other links use for their active-underline) - Fix stat card labels getting truncated on mobile by removing the ellipsis-cut label and widening the mobile grid to one column - Fix Revenue trend / Top performing events rendering outside the viewport on mobile by containing horizontal overflow and truncating long event titles in the table
249 lines
13 KiB
TypeScript
249 lines
13 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 {
|
|
Calendar, Banknote, Gift, Users, Ticket, QrCode, DoorOpen, Wallet,
|
|
UserPlus, FileText, MessageCircle, BarChart2, Mail,
|
|
} from "lucide-react";
|
|
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
|
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
|
import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
|
|
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
|
|
|
const formatRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
|
|
const formatRandAxis = (n: number) => `R${new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(n)}`;
|
|
const formatCount = (n: number) => (n || 0).toLocaleString();
|
|
const REPORTS_URL = "/dashboard/supervisor/reports";
|
|
|
|
const QUICK_ACTIONS = [
|
|
{ href: "/dashboard/supervisor/manual", label: "Manual registration", description: "Register a guest and issue tickets", icon: UserPlus },
|
|
{ href: "/dashboard/supervisor/events", label: "Manage events", description: "Create, edit, and update ticket types", icon: Calendar },
|
|
{ href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
|
|
{ href: "/dashboard/staff/ticket-scanning", label: "Open scanner", description: "Use your device camera to validate tickets", icon: QrCode },
|
|
{ href: "/dashboard/supervisor/at-the-door", label: "At the door", description: "Walk-ins, payments, ticket printing", icon: DoorOpen },
|
|
{ href: "/dashboard/supervisor/reports", label: "Reports", description: "View, export, and email reports", icon: BarChart2 },
|
|
{ href: "/dashboard/supervisor/forms", label: "Attendee forms", description: "View submitted attendee forms", icon: FileText },
|
|
{ href: "/dashboard/supervisor/email-attendees", label: "Email attendees", description: "Send a message to attendees of an event", icon: Mail },
|
|
{ href: "/dashboard/supervisor/whatsapp-attendees", label: "WhatsApp attendees", description: "Send a WhatsApp message to event attendees", icon: MessageCircle },
|
|
] as const;
|
|
|
|
type OverviewMetric = { thisMonth: number; lastMonth: number; pctChange: number | null };
|
|
type Overview = {
|
|
activeEvents: number;
|
|
registrations: OverviewMetric;
|
|
ticketsSold: OverviewMetric;
|
|
revenue: OverviewMetric;
|
|
donations: OverviewMetric;
|
|
trend: { date: string; revenue: number }[];
|
|
topEvents: { eventId: string; title: string; revenue: number; registrations: number; ticketsSold: number }[];
|
|
};
|
|
|
|
export default function SupervisorDashboardPage() {
|
|
const { user, loading, token } = useAuth();
|
|
const router = useRouter();
|
|
|
|
const canView = useMemo(() => {
|
|
const role = user?.role;
|
|
return role === "admin" || role === "supervisor";
|
|
}, [user]);
|
|
|
|
useEffect(() => {
|
|
if (loading) return;
|
|
if (!user) router.replace("/login");
|
|
}, [user, loading, router]);
|
|
|
|
// /api/stats/supervisor covers scan activity + today's payments (unchanged from before);
|
|
// /api/stats/overview is the month-over-month KPI/trend/top-events endpoint, shared
|
|
// with the admin dashboard since supervisors already have full revenue/donation visibility.
|
|
const [scanStats, setScanStats] = useStableState<any | null>(null);
|
|
const [paymentStats, setPaymentStats] = useStableState<any | null>(null);
|
|
const [recentScans, setRecentScans] = useStableState<any[]>([]);
|
|
const [overview, setOverview] = useStableState<Overview | null>(null);
|
|
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, overviewData] = await Promise.all([
|
|
apiFetch<any>("/api/stats/supervisor", { authToken: token }),
|
|
apiFetch<Overview>("/api/stats/overview", { authToken: token }),
|
|
]);
|
|
setScanStats(data.scanStats);
|
|
setPaymentStats(data.paymentStats);
|
|
setRecentScans(Array.isArray(data.recentScans) ? data.recentScans : []);
|
|
setOverview(overviewData);
|
|
} catch (e) {
|
|
// ignore
|
|
} finally {
|
|
hasLoadedOnce.current = true;
|
|
if (isFirstLoad) setLoadingStats(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!token) return;
|
|
loadStats();
|
|
}, [token]);
|
|
|
|
// Poll every 15s 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();
|
|
}, 15000, !!token);
|
|
|
|
return (
|
|
<div className="max-w-6xl mx-auto w-full overflow-x-hidden">
|
|
<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 what's happening with your events today.</p>
|
|
</div>
|
|
|
|
{!canView && (
|
|
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
|
|
You need supervisor or admin access to use these tools.
|
|
</div>
|
|
)}
|
|
|
|
<StatCardRow>
|
|
<StatCard icon={Calendar} label="Active events" value={overview ? formatCount(overview.activeEvents) : "—"} tone="brand" href="/dashboard/supervisor/events" linkLabel="View all events" />
|
|
<StatCard icon={Banknote} label="Revenue (past month)" value={overview ? formatRand(overview.revenue.thisMonth) : "—"} tone="green" href={`${REPORTS_URL}?report=revenue&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.revenue.pctChange } : undefined} />
|
|
<StatCard icon={Gift} label="Donations (past month)" value={overview ? formatRand(overview.donations.thisMonth) : "—"} tone="rose" href={`${REPORTS_URL}?report=donations&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.donations.pctChange } : undefined} />
|
|
<StatCard icon={Users} label="Registrations (past month)" value={overview ? formatCount(overview.registrations.thisMonth) : "—"} tone="blue" href={`${REPORTS_URL}?report=regStatus&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.registrations.pctChange } : undefined} />
|
|
<StatCard icon={Ticket} label="Tickets sold (past month)" value={overview ? formatCount(overview.ticketsSold.thisMonth) : "—"} tone="amber" href={`${REPORTS_URL}?report=usage&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.ticketsSold.pctChange } : undefined} />
|
|
</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 space-y-6 min-w-0">
|
|
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
|
<h2 className="text-lg font-semibold mb-3">Revenue trend — past month</h2>
|
|
{overview && overview.trend.length > 0 ? (
|
|
<AreaTrendChart data={overview.trend.map(t => ({ label: t.date.slice(5), value: t.revenue }))} valueFormatter={formatRand} axisFormatter={formatRandAxis} />
|
|
) : (
|
|
<div className="text-sm text-gray-400">No revenue recorded in the past month.</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
|
<h2 className="text-lg font-semibold mb-3">Top performing events</h2>
|
|
{overview && overview.topEvents.length > 0 ? (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Event</TableHead>
|
|
<TableHead className="text-right">Registrations</TableHead>
|
|
<TableHead className="text-right">Revenue</TableHead>
|
|
<TableHead className="text-right">Tickets sold</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{overview.topEvents.map(e => (
|
|
<TableRow key={e.eventId}>
|
|
<TableCell className="font-medium max-w-[140px] sm:max-w-[220px] truncate" title={e.title}>{e.title}</TableCell>
|
|
<TableCell className="text-right">{formatCount(e.registrations)}</TableCell>
|
|
<TableCell className="text-right">{formatRand(e.revenue)}</TableCell>
|
|
<TableCell className="text-right">{formatCount(e.ticketsSold)}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
) : (
|
|
<div className="text-sm text-gray-400">No event revenue recorded yet.</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>
|
|
{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 className="space-y-6 min-w-0">
|
|
<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">Scanner activity</h2>
|
|
{loadingStats && <span className="text-xs text-gray-500">Refreshing…</span>}
|
|
</div>
|
|
{scanStats ? (
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<div className="border rounded p-3 bg-white">
|
|
<div className="text-xs text-gray-500">Today</div>
|
|
<div className="text-lg font-semibold">{scanStats.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">{scanStats.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">{scanStats.lastHour}</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="text-sm text-gray-500">No scanner data yet.</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
|
<h2 className="text-lg font-semibold mb-3">Payments today</h2>
|
|
{paymentStats ? (
|
|
<div className="border rounded-lg p-3 flex items-center justify-between">
|
|
<div className="text-sm text-gray-500">Today</div>
|
|
<div className="text-base font-semibold">{formatRand(paymentStats.totalToday)}</div>
|
|
</div>
|
|
) : (
|
|
<div className="text-sm text-gray-500">No payment data yet.</div>
|
|
)}
|
|
|
|
{scanStats?.byStaff?.length > 0 && (
|
|
<div className="mt-4">
|
|
<div className="text-sm font-medium mb-1">Today by staff</div>
|
|
<ul className="text-sm text-gray-700 space-y-1">
|
|
{scanStats.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>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|