- 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
205 lines
11 KiB
TypeScript
205 lines
11 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, ClipboardList,
|
|
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen,
|
|
} 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/admin/users", label: "Manage users", description: "Create, edit, change roles and passwords", icon: Users },
|
|
{ href: "/dashboard/supervisor/events", label: "Manage events", description: "Create, edit, and update ticket types", icon: Calendar },
|
|
{ href: "/dashboard/admin/registrations", label: "Manage registrations", description: "Cancel, update status, and search registrations", icon: ClipboardList },
|
|
{ href: "/dashboard/supervisor/manual", label: "Manual registration", description: "Register a guest and issue tickets", icon: UserPlus },
|
|
{ href: "/dashboard/supervisor/at-the-door", label: "At the door", description: "Walk-ins, payments, ticket printing", icon: DoorOpen },
|
|
{ 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/reports", label: "Reports", description: "View, export, and email reports", icon: BarChart2 },
|
|
{ href: "/dashboard/admin/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 },
|
|
{ href: "/dashboard/admin/cashup", label: "Post-event Cashup", description: "Set costs, reconcile takings, and close out events", icon: Wallet },
|
|
] 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 AdminDashboardPage() {
|
|
const { user, loading, token } = useAuth();
|
|
const router = useRouter();
|
|
|
|
const isAdmin = useMemo(() => (user?.role === "admin"), [user]);
|
|
|
|
useEffect(() => {
|
|
if (loading) return;
|
|
if (!user) router.replace("/login");
|
|
}, [user, loading, router]);
|
|
|
|
// /api/stats/admin covers today/week/month payment totals (unchanged from before);
|
|
// /api/stats/overview is the month-over-month KPI/trend/top-events endpoint. Both are
|
|
// computed server-side — no full payments/events list ever ships to the client.
|
|
const [paymentStats, setPaymentStats] = useStableState<any | null>(null);
|
|
const [overview, setOverview] = useStableState<Overview | null>(null);
|
|
const [loadingStats, setLoadingStats] = useState(false);
|
|
const hasLoadedOnce = useRef(false);
|
|
const [paymentsTab, setPaymentsTab] = useState<"today" | "week" | "month">("today");
|
|
|
|
const loadStats = async () => {
|
|
if (!token) return;
|
|
const isFirstLoad = !hasLoadedOnce.current;
|
|
try {
|
|
if (isFirstLoad) setLoadingStats(true);
|
|
const [dash, overviewData] = await Promise.all([
|
|
apiFetch<any>("/api/stats/admin", { authToken: token }),
|
|
apiFetch<Overview>("/api/stats/overview", { authToken: token }),
|
|
]);
|
|
setPaymentStats(dash.paymentStats);
|
|
setOverview(overviewData);
|
|
} catch (e) {
|
|
// ignore errors for dashboard summaries
|
|
} 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);
|
|
|
|
const paymentsTabValue = paymentStats
|
|
? paymentsTab === "today" ? paymentStats.totalToday : paymentsTab === "week" ? paymentStats.totalWeek : paymentStats.totalMonth
|
|
: 0;
|
|
|
|
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>
|
|
|
|
{!isAdmin && (
|
|
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
|
|
You need 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="/dashboard/admin/registrations" linkLabel="View registrations" 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>
|
|
|
|
<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">Payments overview</h2>
|
|
{loadingStats && <span className="text-xs text-gray-500">Refreshing…</span>}
|
|
</div>
|
|
<div className="flex gap-1 mb-3 bg-gray-100 rounded-lg p-1">
|
|
{(["today", "week", "month"] as const).map(t => (
|
|
<button
|
|
key={t}
|
|
type="button"
|
|
onClick={() => setPaymentsTab(t)}
|
|
className={"flex-1 text-xs font-medium py-1.5 rounded-md transition-colors " + (paymentsTab === t ? "bg-white text-brand-700 shadow-sm" : "text-gray-500 hover:text-gray-700")}
|
|
>
|
|
{t === "today" ? "Today" : t === "week" ? "Past week" : "Past month"}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{paymentStats ? (
|
|
<div className="text-2xl font-semibold text-gray-900">{formatRand(paymentsTabValue)}</div>
|
|
) : (
|
|
<div className="text-sm text-gray-500">No payment data yet.</div>
|
|
)}
|
|
</div>
|
|
|
|
<p className="text-sm text-gray-600">As an admin you can access Supervisor and Staff tools. Use the quick actions above to jump to common tasks.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|