- Admin -> Privacy (POPIA): edit the privacy notice (built-in text as the
start, placeholders for org/IO/operators), drafts, preview, numbered
versions; earlier versions readable on /legal/privacy?version=N
- "Significant change" versions are accepted once after login
(NoticePrompt); sign-up records the version accepted
- Operator register (seeded with the services the app uses), listed in
the notice via {operators}
- Consent log (news opt-ins, notice acceptances, parental consent) and
access log (report exports, registers, certificates, group lists, form
answers, consent/medical lists, backups, impersonation)
- Information Officer on the contact page
- Unit tests (privacyNotice.test.js), e2e (privacy-notice.spec.ts); docs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012mt6TCv85T9VKRq3cV7rXq
220 lines
13 KiB
TypeScript
220 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import React, { useCallback, 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, History, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter, ClipboardCheck, Inbox,
|
|
} 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";
|
|
import type { DashboardStats, PaymentStats } from "@/types";
|
|
|
|
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/supervisor/allocations", label: "Rooms & groups", description: "Put attendees into rooms, groups or buses", icon: BedDouble },
|
|
{ href: "/dashboard/supervisor/offline-scans", label: "Offline scanning", description: "Devices scanning offline and their synced scans", icon: WifiOff },
|
|
{ href: "/dashboard/supervisor/attendance", label: "Attendance", description: "Who came to each session, and certificates", icon: ClipboardCheck },
|
|
{ href: "/dashboard/supervisor/waitlist", label: "Waitlist", description: "People waiting for sold-out tickets", icon: ListChecks },
|
|
{ href: "/dashboard/supervisor/consent", label: "Parental consent", description: "Youth events: consent status and medical list", icon: ShieldCheck },
|
|
{ href: "/dashboard/supervisor/coupons", label: "Coupon codes", description: "Discounts for staff, leaders and promotions", icon: TicketPercent },
|
|
{ href: "/dashboard/supervisor/bundles", label: "Bundle deals", description: "Tickets sold together for one price", icon: Gift },
|
|
{ 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 },
|
|
{ href: "/dashboard/admin/audit-log", label: "Audit log", description: "Review refunds, manual registrations, event and settings changes", icon: History },
|
|
{ href: "/dashboard/admin/data-requests", label: "Data requests", description: "POPIA: people asking to correct or delete their information", icon: Inbox },
|
|
{ href: "/dashboard/admin/privacy", label: "Privacy (POPIA)", description: "Privacy notice, operators, consent and access logs", icon: ShieldCheck },
|
|
] as const;
|
|
|
|
type OverviewMetric = { thisMonth: number; lastMonth: number; pctChange: number | null };
|
|
type Overview = {
|
|
funnel?: { views: number; paid: number; conversion: number | null } | null;
|
|
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<PaymentStats | 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 = useCallback(async () => {
|
|
if (!token) return;
|
|
const isFirstLoad = !hasLoadedOnce.current;
|
|
try {
|
|
if (isFirstLoad) setLoadingStats(true);
|
|
const [dash, overviewData] = await Promise.all([
|
|
apiFetch<DashboardStats>("/api/stats/admin", { authToken: token }),
|
|
apiFetch<Overview>("/api/stats/overview", { authToken: token }),
|
|
]);
|
|
setPaymentStats(dash.paymentStats);
|
|
setOverview(overviewData);
|
|
} catch {
|
|
// ignore errors for dashboard summaries
|
|
} finally {
|
|
hasLoadedOnce.current = true;
|
|
if (isFirstLoad) setLoadingStats(false);
|
|
}
|
|
}, [token, setPaymentStats, setOverview]);
|
|
|
|
useEffect(() => {
|
|
if (!token) return;
|
|
loadStats();
|
|
}, [token, loadStats]);
|
|
|
|
// 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
|
|
: 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 cols={overview?.funnel ? 6 : 5}>
|
|
<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} />
|
|
{overview?.funnel && (
|
|
<StatCard icon={Filter} label="Conversion (past month)" value={overview.funnel.conversion != null ? `${overview.funnel.conversion}%` : "—"} tone="violet" href={`${REPORTS_URL}?report=funnel&range=trailing_month`} linkLabel={`${overview.funnel.paid} of ${overview.funnel.views} visits`} />
|
|
)}
|
|
</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>
|
|
);
|
|
}
|