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:
2026-08-06 15:00:10 +02:00
co-authored by Claude Sonnet 5
parent d74fec3a5c
commit 8e6cb542d9
119 changed files with 5116 additions and 4316 deletions
+6 -6
View File
@@ -84,7 +84,7 @@ export function LoginForm() {
<button
type="button"
onClick={() => setShowPassword((s) => !s)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-sm text-blue-600"
className="absolute right-2 top-1/2 -translate-y-1/2 text-sm text-brand-600"
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? "Hide" : "Show"}
@@ -93,7 +93,7 @@ export function LoginForm() {
<div className="mt-1">
<a
href="/forgot-password"
className="text-xs text-blue-600 hover:underline"
className="text-xs text-brand-600 hover:underline"
>
Forgot your password?
</a>
@@ -101,7 +101,7 @@ export function LoginForm() {
<div className="mt-1">
<a
href={registerHref}
className="text-xs text-blue-600 hover:underline"
className="text-xs text-brand-600 hover:underline"
>
Don't have an account? Sign up here.
</a>
@@ -120,7 +120,7 @@ export function LoginForm() {
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white rounded py-2 disabled:opacity-60"
className="w-full bg-brand-600 hover:bg-brand-700 text-white rounded py-2 disabled:opacity-60"
>
{loading ? "Logging in..." : "Login"}
</button>
@@ -130,7 +130,7 @@ export function LoginForm() {
<Link
href="/legal/terms"
target="_blank"
className="text-blue-600 hover:underline"
className="text-brand-600 hover:underline"
>
Terms of Use
</Link>{" "}
@@ -138,7 +138,7 @@ export function LoginForm() {
<Link
href="/legal/privacy"
target="_blank"
className="text-blue-600 hover:underline"
className="text-brand-600 hover:underline"
>
Privacy Policy
</Link>.
@@ -134,7 +134,7 @@ export function RegisterForm() {
type="checkbox"
checked={accepted}
onChange={(e) => setAccepted(e.target.checked)}
className="mt-1 h-4 w-4 border-gray-300 rounded accent-blue-600"
className="mt-1 h-4 w-4 border-gray-300 rounded accent-brand-600"
required
/>
<label htmlFor="terms" className="text-sm text-gray-700 leading-snug">
@@ -142,7 +142,7 @@ export function RegisterForm() {
<Link
href="/legal/terms"
target="_blank"
className="text-blue-600 hover:underline"
className="text-brand-600 hover:underline"
>
Terms of Use
</Link>{" "}
@@ -150,7 +150,7 @@ export function RegisterForm() {
<Link
href="/legal/privacy"
target="_blank"
className="text-blue-600 hover:underline"
className="text-brand-600 hover:underline"
>
Privacy Policy
</Link>.
@@ -160,7 +160,7 @@ export function RegisterForm() {
<div className="mt-1">
<a
href={loginHref}
className="text-xs text-blue-600 hover:underline"
className="text-xs text-brand-600 hover:underline"
>
Already have an account? Sign in here.
</a>
@@ -173,7 +173,7 @@ export function RegisterForm() {
disabled={loading || !accepted}
className={`w-full rounded py-2 text-white transition ${
accepted
? "bg-blue-600 hover:bg-blue-700"
? "bg-brand-600 hover:bg-brand-700"
: "bg-gray-400 cursor-not-allowed"
}`}
>
@@ -0,0 +1,103 @@
"use client";
import React from "react";
import { BRAND_600 } from "@/lib/theme";
export type TrendDatum = { label: string; value: number };
// Dependency-free single-series area/line chart, following the same
// hand-rolled-SVG approach as reports/charts/HorizontalBarChart.tsx. A
// single continuous series over time calls for a line/area (dataviz skill
// form heuristic), so this uses one brand-consistent color rather than the
// bar chart's multi-category palette.
const WIDTH = 600;
const HEIGHT = 220;
const PADDING_LEFT = 48; // room for y-axis value labels
const PADDING_RIGHT = 8;
const PADDING_TOP = 12;
const PADDING_BOTTOM = 24;
const GRIDLINES = 4;
const defaultAxisFormatter = (v: number) => new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(v);
export function AreaTrendChart({
data,
valueFormatter,
axisFormatter,
}: {
data: TrendDatum[];
/** Formats the headline "Latest" value — full precision is fine here. */
valueFormatter?: (v: number) => string;
/** Formats the y-axis gridline labels — should stay compact (little horizontal room). Defaults to a compact number (e.g. "1.5k"). */
axisFormatter?: (v: number) => string;
}) {
const fmt = valueFormatter || ((v: number) => String(v));
const axisFmt = axisFormatter || defaultAxisFormatter;
if (data.length === 0) {
return <div className="text-xs text-gray-400">No data to chart.</div>;
}
const max = Math.max(1, ...data.map(d => d.value));
const min = Math.min(0, ...data.map(d => d.value));
const range = max - min || 1;
const plotWidth = WIDTH - PADDING_LEFT - PADDING_RIGHT;
const plotHeight = HEIGHT - PADDING_TOP - PADDING_BOTTOM;
const baseline = PADDING_TOP + plotHeight;
const points = data.map((d, i) => {
const x = data.length === 1 ? PADDING_LEFT + plotWidth / 2 : PADDING_LEFT + (i / (data.length - 1)) * plotWidth;
const y = PADDING_TOP + plotHeight - ((d.value - min) / range) * plotHeight;
return { x, y, value: d.value };
});
const linePath = points.map((p, i) => (i === 0 ? `M ${p.x} ${p.y}` : `L ${p.x} ${p.y}`)).join(" ");
const areaPath = `${linePath} L ${points[points.length - 1].x} ${baseline} L ${points[0].x} ${baseline} Z`;
// Horizontal gridlines from 0 up to the max, evenly spaced, with a value label on each.
const gridlines = Array.from({ length: GRIDLINES + 1 }, (_, i) => {
const value = min + (range * i) / GRIDLINES;
const y = PADDING_TOP + plotHeight - (i / GRIDLINES) * plotHeight;
return { y, value };
});
// Show at most ~6 x-axis labels so long series don't crowd the axis.
const labelStep = Math.max(1, Math.ceil(data.length / 6));
const visibleLabels = data.filter((_, i) => i % labelStep === 0 || i === data.length - 1);
const latest = data[data.length - 1];
return (
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-gray-500">Latest</span>
<span className="text-sm font-semibold text-gray-900">{fmt(latest.value)}</span>
</div>
<svg viewBox={`0 0 ${WIDTH} ${HEIGHT}`} className="w-full h-auto" role="img" aria-label="Trend chart">
<defs>
<linearGradient id="areaTrendFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={BRAND_600} stopOpacity="0.25" />
<stop offset="100%" stopColor={BRAND_600} stopOpacity="0" />
</linearGradient>
</defs>
{gridlines.map((g, i) => (
<g key={i}>
<line x1={PADDING_LEFT} y1={g.y} x2={WIDTH - PADDING_RIGHT} y2={g.y} stroke="#F3F4F6" strokeWidth={1} />
<text x={PADDING_LEFT - 6} y={g.y} textAnchor="end" dominantBaseline="middle" className="fill-gray-400" fontSize={10}>
{axisFmt(g.value)}
</text>
</g>
))}
<path d={areaPath} fill="url(#areaTrendFill)" stroke="none" />
<path d={linePath} fill="none" stroke={BRAND_600} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
{points.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={data.length === 1 ? 4 : 3} fill="#fff" stroke={BRAND_600} strokeWidth={2} />
))}
</svg>
<div className="flex justify-between mt-1 ml-12 text-[10px] text-gray-400">
{visibleLabels.map((d, i) => (
<span key={d.label + i}>{d.label}</span>
))}
</div>
</div>
);
}
+18 -8
View File
@@ -1,4 +1,5 @@
import { ApiImage } from "@/components/shared/ApiImage";
import { Calendar } from "lucide-react";
type Event = {
id: string;
@@ -19,12 +20,21 @@ export const EventCard = ({ event }: { event: Event }) => {
const dateRange = formatDateTimeRange(event.startDate, event.endDate);
return (
<div className="border rounded-xl overflow-hidden shadow-sm hover:shadow-md transition">
<div className="border rounded-xl overflow-hidden shadow-sm hover:shadow-md hover:border-brand-200 transition">
<a href={`/events/${event.id}`} className="block">
<ApiImage src={event.picture} alt={event.title} className="h-48 w-full object-cover" />
{event.picture ? (
<ApiImage src={event.picture} alt={event.title} className="h-48 w-full object-cover" />
) : (
<div className="h-48 w-full bg-brand-50 flex items-center justify-center">
<Calendar className="w-10 h-10 text-brand-200" />
</div>
)}
<div className="p-4">
<h3 className="text-lg font-semibold hover:underline">{event.title}</h3>
<p className="text-sm text-gray-500">{dateRange}</p>
<p className="text-sm text-gray-500 flex items-center gap-1.5 mt-1">
<Calendar className="w-3.5 h-3.5 shrink-0" />
{dateRange}
</p>
<p className="text-sm text-gray-600 mt-2 line-clamp-2">{event.description}</p>
</div>
</a>
@@ -32,7 +42,7 @@ export const EventCard = ({ event }: { event: Event }) => {
<div className="flex gap-2">
<a
href={`/events/${event.id}`}
className="flex-1 text-center text-sm text-gray-700 border py-2 rounded hover:bg-gray-50"
className="flex-1 text-center text-sm text-gray-700 border rounded-lg py-2 hover:bg-gray-50 transition-colors"
>
View details
</a>
@@ -45,21 +55,21 @@ export const EventCard = ({ event }: { event: Event }) => {
const closed = (deadline ? now >= deadline : false) || now >= end;
if (notYetOpen) {
return (
<button disabled className="flex-1 text-center text-sm text-gray-500 bg-gray-200 py-2 rounded cursor-not-allowed" title="Registration not yet open">
<button disabled className="flex-1 text-center text-sm text-gray-500 bg-gray-200 rounded-lg py-2 cursor-not-allowed" title="Registration not yet open">
Opens {goLive?.toLocaleString()}
</button>
);
}
if (closed) {
return (
<button disabled className="flex-1 text-center text-sm text-gray-500 bg-gray-200 py-2 rounded cursor-not-allowed" title="Registration closed">
<button disabled className="flex-1 text-center text-sm text-gray-500 bg-gray-200 rounded-lg py-2 cursor-not-allowed" title="Registration closed">
Registration closed
</button>
);
}
if (event.isSoldOut) {
return (
<button disabled className="flex-1 text-center text-sm text-red-700 bg-red-50 border border-red-200 py-2 rounded cursor-not-allowed">
<button disabled className="flex-1 text-center text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg py-2 cursor-not-allowed">
Sold Out
</button>
);
@@ -67,7 +77,7 @@ export const EventCard = ({ event }: { event: Event }) => {
return (
<a
href={`/register/${event.id}`}
className="flex-1 text-center text-sm text-white bg-blue-600 py-2 rounded hover:bg-blue-700"
className="flex-1 text-center text-sm text-white bg-brand-600 rounded-lg py-2 hover:bg-brand-700 transition-colors"
>
Register{event.price > 0 ? ` - R${event.price.toFixed(2)}` : ""}
</a>
@@ -1,150 +0,0 @@
"use client";
import React, { useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, resolveToApiOrigin } from "@/lib/api";
import { Button } from "@/components/shared/Button";
export function EventForm({ onCreated }: { onCreated?: () => void }) {
const { token } = useAuth();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [price, setPrice] = useState<number>(0);
const [picture, setPicture] = useState("");
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [uploadingImage, setUploadingImage] = useState(false);
const [isHidden, setIsHidden] = useState(false);
const [requiresAuth, setRequiresAuth] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleImageFile(file: File) {
setUploadingImage(true);
setError(null);
try {
const fd = new FormData();
fd.append("image", file);
const data = await apiFetch<{ url: string }>("/api/uploads/event-image", {
method: "POST",
authToken: token,
body: fd,
});
setPicture(data.url);
setImagePreview(URL.createObjectURL(file));
} catch (e: any) {
setError(e?.message || "Image upload failed");
} finally {
setUploadingImage(false);
}
}
function clearImage() {
setImagePreview(null);
setPicture("");
}
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!token) { setError("Please login"); return; }
setLoading(true);
setError(null);
try {
await apiFetch("/api/events", {
method: "POST",
authToken: token,
body: { title, description, startDate, endDate, price, picture, isHidden, requiresAuth },
});
onCreated?.();
setTitle(""); setDescription(""); setStartDate(""); setEndDate(""); setPrice(0); setPicture(""); setImagePreview(null); setIsHidden(false); setRequiresAuth(true);
} catch (e: any) {
setError(e?.message || "Failed to create event");
} finally {
setLoading(false);
}
}
const previewSrc = imagePreview || (picture ? resolveToApiOrigin(picture) : null);
return (
<form onSubmit={submit} className="space-y-3">
<div>
<label className="block text-sm font-medium">Title</label>
<input className="w-full border rounded px-3 py-2" value={title} onChange={(e) => setTitle(e.target.value)} required />
</div>
<div>
<label className="block text-sm font-medium">Description</label>
<textarea className="w-full border rounded px-3 py-2" value={description} onChange={(e) => setDescription(e.target.value)} />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium">Start</label>
<input type="datetime-local" className="w-full border rounded px-3 py-2" value={startDate} onChange={(e) => setStartDate(e.target.value)} required />
</div>
<div>
<label className="block text-sm font-medium">End</label>
<input type="datetime-local" className="w-full border rounded px-3 py-2" value={endDate} onChange={(e) => setEndDate(e.target.value)} required />
</div>
</div>
<div>
<label className="block text-sm font-medium">Price (R)</label>
<input type="number" step="0.01" className="w-full border rounded px-3 py-2" value={price} onChange={(e) => setPrice(parseFloat(e.target.value))} />
</div>
<div>
<label className="block text-sm font-medium mb-1">Event image</label>
{previewSrc && (
<div className="relative inline-block mb-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={previewSrc} alt="Preview" className="h-32 object-cover rounded border" />
<button
type="button"
onClick={clearImage}
className="absolute top-1 right-1 bg-white text-red-500 hover:text-red-700 rounded px-1 text-xs shadow"
></button>
</div>
)}
<div className="flex gap-2 items-center">
<label className={`cursor-pointer px-3 py-2 text-sm rounded border bg-gray-50 hover:bg-gray-100 ${uploadingImage ? "opacity-50 pointer-events-none" : ""}`}>
<input
type="file"
accept="image/*"
className="hidden"
disabled={uploadingImage}
onChange={e => {
const file = e.target.files?.[0];
if (file) handleImageFile(file);
e.target.value = "";
}}
/>
{uploadingImage ? "Uploading…" : "Upload image"}
</label>
<span className="text-xs text-gray-400">or</span>
<input
className="flex-1 border rounded px-3 py-2 text-sm"
placeholder="https://…"
value={picture}
onChange={e => { setPicture(e.target.value); setImagePreview(null); }}
/>
</div>
</div>
<div className="border rounded-lg p-3 bg-gray-50 space-y-2">
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Visibility &amp; Access</div>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={isHidden} onChange={e => setIsHidden(e.target.checked)} />
<span className="text-sm text-gray-700">
<span className="font-medium">Hidden event</span> not shown in the public listing; accessible by direct link only
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={!requiresAuth} onChange={e => setRequiresAuth(!e.target.checked)} />
<span className="text-sm text-gray-700">
<span className="font-medium">Allow unauthenticated registration</span> guests can register without creating an account
</span>
</label>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<Button type="submit" loading={loading}>Create Event</Button>
</form>
);
}
+5 -5
View File
@@ -4,14 +4,14 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { Home, Calendar, Phone, LogIn, UserPlus, LayoutDashboard, LogOut } from "lucide-react";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { brandColor } from "@/lib/siteConfig";
import { BRAND_600 } from "@/lib/theme";
export const BottomNav = () => {
const { user, loading: authLoading, logout } = useAuth();
const pathname = usePathname();
const { settings } = useSiteSettings();
const accentColor = settings.accent_color || brandColor;
// Fixed brand color — unlike the navbar's org name, nothing here is
// admin-configurable (see globals.css for the rule).
const accentColor = BRAND_600;
const handleLogout = () => {
logout();
@@ -32,7 +32,7 @@ export const BottomNav = () => {
const items: NavItem[] = [
{ href: "/", label: "Home", icon: Home },
{ href: "/events", label: "Events", icon: Calendar },
{ href: "/#contact", label: "Contact", icon: Phone },
{ href: "/contact", label: "Contact", icon: Phone },
...(authLoading
? []
: user
+25 -4
View File
@@ -2,6 +2,7 @@
import Link from "next/link";
import Image from "next/image";
import { usePathname } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { BottomNav } from "./BottomNav";
@@ -18,10 +19,20 @@ type NavLink = {
export const Navbar = () => {
const { user, loading: authLoading, logout } = useAuth();
const { settings, loading: settingsLoading } = useSiteSettings();
const pathname = usePathname();
// Admin-configurable — applied ONLY to the org name text below via inline
// style. No other element in the navbar (or anywhere else) should read
// this; everything else uses the fixed brand-* palette.
const displayName = settings.org_name || appName;
const displayColor = settings.accent_color || brandColor;
const logoSrc = settings.logo_url ? resolveToApiOrigin(settings.logo_url) : null;
const isActive = (href: string) => {
if (href.includes("#")) return false;
if (href === "/") return pathname === "/";
return pathname?.startsWith(href) ?? false;
};
const handleLogout = () => {
logout();
window.location.href = "/";
@@ -30,7 +41,7 @@ export const Navbar = () => {
const navLinks: NavLink[] = [
{ href: "/", label: "Home" },
{ href: "/events", label: "Events" },
{ href: "/#contact", label: "Contact" },
{ href: "/contact", label: "Contact" },
];
const authLinks: NavLink[] = user
@@ -78,7 +89,12 @@ export const Navbar = () => {
<Link
key={link.href}
href={link.href}
className="text-sm text-gray-700 hover:text-blue-600"
className={
"text-sm pb-0.5 border-b-2 transition-colors " +
(isActive(link.href)
? "text-brand-600 border-brand-600 font-medium"
: "text-gray-700 border-transparent hover:text-brand-600")
}
>
{link.label}
</Link>
@@ -92,7 +108,7 @@ export const Navbar = () => {
<button
key={link.label}
onClick={link.onClick}
className="text-sm text-gray-700 hover:text-blue-600"
className="text-sm text-gray-700 hover:text-brand-600"
>
{link.label}
</button>
@@ -100,7 +116,12 @@ export const Navbar = () => {
<Link
key={link.href}
href={link.href}
className="text-sm text-gray-700 hover:text-blue-600"
className={
"text-sm pb-0.5 border-b-2 transition-colors " +
(isActive(link.href)
? "text-brand-600 border-brand-600 font-medium"
: "text-gray-700 border-transparent hover:text-brand-600")
}
>
{link.label}
</Link>
+73 -79
View File
@@ -1,106 +1,100 @@
"use client";
import React from "react";
import React, { useState } from "react";
import Link from "next/link";
import { Calendar, User, LayoutDashboard, ShieldCheck, Globe, Menu, type LucideIcon } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { RoleBadge } from "@/components/shared/RoleBadge";
import { useRouter, usePathname } from "next/navigation";
import { RoleBadge, type Role } from "@/components/shared/RoleBadge";
import { UserAvatar } from "@/components/shared/UserAvatar";
import { usePathname } from "next/navigation";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
const navLinks: { href: string; label: string; roles?: string[] }[] = [
{ href: "/dashboard/user", label: "My Events", roles: ["user", "staff", "supervisor", "admin"] },
{ href: "/dashboard/user/profile", label: "Profile & Security", roles: ["user", "staff", "supervisor", "admin"] },
{ href: "/dashboard/staff", label: "Staff", roles: ["staff"] },
{ href: "/dashboard/supervisor", label: "Supervisor", roles: ["supervisor"] },
{ href: "/dashboard/admin", label: "Admin", roles: ["admin"] },
{ href: "/dashboard/admin/settings", label: "Site Settings", roles: ["admin"] }
const navLinks: { href: string; label: string; icon: LucideIcon; roles?: string[] }[] = [
{ href: "/dashboard/user", label: "My Events", icon: Calendar, roles: ["user", "staff", "supervisor", "admin"] },
{ href: "/dashboard/user/profile", label: "Profile & Security", icon: User, roles: ["user", "staff", "supervisor", "admin"] },
{ href: "/dashboard/staff", label: "Staff", icon: LayoutDashboard, roles: ["staff"] },
{ href: "/dashboard/supervisor", label: "Supervisor", icon: LayoutDashboard, roles: ["supervisor"] },
{ href: "/dashboard/admin", label: "Admin", icon: ShieldCheck, roles: ["admin"] },
{ href: "/dashboard/admin/settings", label: "Site Settings", icon: Globe, roles: ["admin"] },
];
export function Sidebar() {
function SidebarNavContent({ onNavigate }: { onNavigate?: () => void }) {
const { user } = useAuth();
const role = user?.role || "user";
const role = (user?.role || "user") as Role;
const pathname = usePathname();
const links = navLinks.filter(l => !l.roles || l.roles.includes(role));
return (
<aside className="w-60 shrink-0 border-r bg-white p-4">
<div className="mb-4">
<div className="font-semibold">Dashboard</div>
{user && (
<div className="mt-1 text-xs text-gray-600 flex items-center space-x-2">
<span>{user.name}</span>
<RoleBadge role={role as any} />
<div className="flex flex-col h-full">
{user && (
<div className="mb-6 flex items-center gap-3">
<UserAvatar name={user.name} />
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 truncate">{user.name}</div>
<RoleBadge role={role} className="mt-0.5" />
</div>
)}
</div>
</div>
)}
<nav className="space-y-1">
{navLinks
.filter((l) => !l.roles || l.roles.includes(role))
.map((l) => (
<Link key={l.href} className="block rounded px-3 py-2 text-sm hover:bg-gray-50" href={l.href}>
{links.map(l => {
const Icon = l.icon;
const active = pathname === l.href;
return (
<Link
key={l.href}
href={l.href}
onClick={onNavigate}
className={
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors " +
(active ? "bg-brand-50 text-brand-700 font-medium" : "text-gray-600 hover:bg-gray-50")
}
>
<Icon className="w-4 h-4 shrink-0" />
{l.label}
</Link>
))}
);
})}
</nav>
</div>
);
}
/** Desktop sidebar — fixed/pinned, does not scroll with page content. */
export function Sidebar() {
return (
<aside className="hidden md:flex md:flex-col w-64 shrink-0 h-full overflow-y-auto border-r bg-white p-4">
<SidebarNavContent />
</aside>
);
}
/** Mobile equivalent — a slim top strip with a hamburger that opens the same nav in a drawer. */
export function MobileSidebar() {
const { user } = useAuth();
const router = useRouter();
const role = user?.role || "user";
const pathname = usePathname();
const options = navLinks.filter((l) => !l.roles || l.roles.includes(role));
// Determine selected value based on current path or default by role
// Use longest matching href to handle nested paths (e.g. /dashboard/user/profile over /dashboard/user)
const selectedFromPath = options
.filter((o) => pathname?.startsWith(o.href))
.sort((a, b) => b.href.length - a.href.length)[0]?.href;
const roleDefault: Record<string, string> = {
admin: "/dashboard/admin",
supervisor: "/dashboard/supervisor",
staff: "/dashboard/staff",
user: "/dashboard/user",
};
const selectedValue = selectedFromPath || roleDefault[role] || options[0]?.href || "";
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value;
if (value && value !== selectedValue) router.push(value);
};
const role = (user?.role || "user") as Role;
const [open, setOpen] = useState(false);
return (
<div className="md:hidden border-b bg-white px-4 py-3 shadow-sm">
<div className="flex items-center justify-between">
<div>
<div className="font-semibold">Dashboard</div>
{user && (
<div className="mt-0.5 text-xs text-gray-600 flex items-center space-x-2">
<span>{user.name}</span>
<RoleBadge role={role as any} />
</div>
)}
<div className="md:hidden border-b bg-white px-4 py-3 shadow-sm flex items-center justify-between shrink-0">
{user && (
<div className="flex items-center gap-2 min-w-0">
<UserAvatar name={user.name} className="w-8 h-8 text-xs" />
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 truncate">{user.name}</div>
<RoleBadge role={role} />
</div>
</div>
</div>
<label className="sr-only" htmlFor="mobile-dashboard-nav">Navigate dashboard</label>
<select
id="mobile-dashboard-nav"
className="mt-3 w-full rounded-md border-gray-300 text-sm py-2 px-3 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
value={selectedValue}
onChange={handleChange}
>
{/* Keep placeholder hidden if a value is selected */}
{!selectedValue && (
<option value="" disabled>
Select section...
</option>
)}
{options.map((l) => (
<option key={l.href} value={l.href}>
{l.label}
</option>
))}
</select>
)}
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<button type="button" aria-label="Open navigation" className="p-2 rounded-md hover:bg-gray-100 shrink-0">
<Menu className="w-5 h-5" />
</button>
</SheetTrigger>
<SheetContent side="left" className="w-72 p-4">
<SidebarNavContent onNavigate={() => setOpen(false)} />
</SheetContent>
</Sheet>
</div>
);
}
+1 -1
View File
@@ -154,7 +154,7 @@ export const QRScanner = React.forwardRef<QRScannerHandle, QRScannerProps>(
<h2 className="text-lg font-semibold">QR Scanner</h2>
<button
onClick={() => (isOn ? stopScan() : startScan())}
className={`px-4 py-2 rounded text-white ${isOn ? "bg-red-600" : "bg-blue-600"}`}
className={`px-4 py-2 rounded text-white ${isOn ? "bg-red-600" : "bg-brand-600"}`}
>
{isOn ? "Stop" : "Scan Ticket"}
</button>
@@ -26,8 +26,8 @@ export default function ReportViewerModal({
<div className="flex items-start justify-between gap-3 sm:gap-4 px-5 pt-4 pb-4">
<div className="flex items-start gap-3 min-w-0">
{Icon && (
<div className="w-11 h-11 rounded-xl bg-indigo-50 flex items-center justify-center shrink-0">
<Icon className="w-5 h-5 text-indigo-600" />
<div className="w-11 h-11 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<Icon className="w-5 h-5 text-brand-600" />
</div>
)}
<div className="min-w-0">
@@ -52,16 +52,16 @@ export default function ReportViewerModal({
)}
</div>
<div className="flex flex-wrap items-center justify-between gap-3 px-5 py-3 border-b bg-indigo-50/50">
<div className="flex items-center gap-2 text-sm text-indigo-900 flex-1 min-w-0">
<Info className="w-4 h-4 text-indigo-400 shrink-0" />
<div className="flex flex-wrap items-center justify-between gap-3 px-5 py-3 border-b bg-brand-50/50">
<div className="flex items-center gap-2 text-sm text-brand-900 flex-1 min-w-0">
<Info className="w-4 h-4 text-brand-400 shrink-0" />
{filters || <span>This report has no extra filters beyond Events and Date range in the sidebar.</span>}
</div>
{onRefresh && (
<button
onClick={onRefresh}
disabled={busy}
className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50"
className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50"
>
<RefreshCw className={"w-3.5 h-3.5 " + (busy ? "animate-spin" : "")} /> {busy ? "Loading…" : "Refresh"}
</button>
@@ -1,221 +0,0 @@
"use client";
import React, { useState } from "react";
import {
X, Home, Filter, ListFilter, Download, BarChart2, MessageCircleQuestion,
Calendar, CalendarClock, EyeOff, Printer, Mail, FileSpreadsheet, MessageCircle,
CreditCard, Gift, Clock, HandHeart, RefreshCw, type LucideIcon,
} from "lucide-react";
type GuideTab = "overview" | "universal" | "specific" | "exporting" | "fields" | "help";
const TABS: { key: GuideTab; label: string; icon: LucideIcon }[] = [
{ key: "overview", label: "Overview", icon: Home },
{ key: "universal", label: "Universal filters", icon: Filter },
{ key: "specific", label: "Report-specific filters", icon: ListFilter },
{ key: "exporting", label: "Exporting reports", icon: Download },
{ key: "fields", label: "Fields & metrics", icon: BarChart2 },
{ key: "help", label: "Need more help?", icon: MessageCircleQuestion },
];
const TONES = {
indigo: { bg: "bg-indigo-50", icon: "text-indigo-600" },
emerald: { bg: "bg-emerald-50", icon: "text-emerald-600" },
amber: { bg: "bg-amber-50", icon: "text-amber-600" },
blue: { bg: "bg-blue-50", icon: "text-blue-600" },
violet: { bg: "bg-violet-50", icon: "text-violet-600" },
rose: { bg: "bg-rose-50", icon: "text-rose-600" },
gray: { bg: "bg-gray-100", icon: "text-gray-600" },
} as const;
type Tone = keyof typeof TONES;
function GuideItem({ icon: Icon, title, children, tone = "gray" }: { icon: LucideIcon; title: string; children: React.ReactNode; tone?: Tone }) {
const t = TONES[tone] || TONES.gray;
return (
<div className="flex items-start gap-3">
<div className={"w-8 h-8 rounded-full flex items-center justify-center shrink-0 " + t.bg}>
<Icon className={"w-4 h-4 " + t.icon} />
</div>
<div>
<div className="font-medium text-gray-800">{title}</div>
<div className="text-xs text-gray-500 mt-0.5">{children}</div>
</div>
</div>
);
}
export const GUIDE_DISMISSED_KEY = "hope_events_reports_guide_dismissed";
const ADMIN_EMAIL = "admin@crosscode.co.za";
export default function ReportingGuideModal({ onClose }: { onClose: (dontShowAgain: boolean) => void }) {
const [tab, setTab] = useState<GuideTab>("overview");
const [dontShowAgain, setDontShowAgain] = useState(false);
return (
// Above the site header (Navbar is `sticky top-0 z-50`) and above the report popup
// (z-[60]), since the guide can be opened while a report is showing.
<div className="fixed inset-0 z-[70]">
<div className="absolute inset-0 bg-black/40" onClick={() => onClose(dontShowAgain)} />
<div className="absolute inset-0 flex items-center justify-center p-4">
<div className="w-full max-w-3xl bg-white rounded-xl shadow-xl" onClick={e => e.stopPropagation()}>
<div className="flex items-start justify-between px-5 py-4 border-b">
<div className="flex items-start gap-3">
<div className="w-9 h-9 rounded-full bg-indigo-50 flex items-center justify-center shrink-0">
<MessageCircleQuestion className="w-5 h-5 text-indigo-600" />
</div>
<div>
<h2 className="text-base font-semibold">Reporting guide</h2>
<p className="text-xs text-gray-500">This guide explains how reports work and how to use the available filters.</p>
</div>
</div>
<button className="p-1.5 rounded hover:bg-gray-100" onClick={() => onClose(dontShowAgain)} aria-label="Close">
<X className="w-5 h-5" />
</button>
</div>
<div className="flex flex-col sm:flex-row">
<nav className="sm:w-56 shrink-0 border-b sm:border-b-0 sm:border-r p-3 space-y-1">
{TABS.map(t => {
const Icon = t.icon;
const active = tab === t.key;
return (
<button
key={t.key}
type="button"
onClick={() => setTab(t.key)}
className={"w-full flex items-center gap-2 text-left text-sm px-3 py-2 rounded-lg " + (active ? "bg-indigo-50 text-indigo-700 font-medium" : "text-gray-600 hover:bg-gray-50")}
>
<Icon className="w-4 h-4" />
{t.label}
</button>
);
})}
</nav>
<div className="flex-1 min-w-0 p-5 text-sm text-gray-700 max-h-[60vh] overflow-auto">
{tab === "overview" && (
<div className="space-y-4">
<p>Reports help you view key data about your events. You can filter the data, preview it on screen, and export or email it.</p>
<div className="space-y-4">
<GuideItem icon={Filter} title="Use filters" tone="indigo">
Apply universal filters (like events and date range) that affect all reports, and report-specific filters for more detailed results.
</GuideItem>
<GuideItem icon={BarChart2} title="Preview & customize" tone="emerald">
Preview your report, adjust filters, and choose how you want the data to appear.
</GuideItem>
<GuideItem icon={Download} title="Export or email" tone="amber">
Export your report to Excel, PDF, or send it by email or WhatsApp.
</GuideItem>
</div>
</div>
)}
{tab === "universal" && (
<div className="space-y-4">
<p>Universal filters live in the sidebar on the left and apply to whichever report you open you only set them once, not per report.</p>
<div className="space-y-4">
<GuideItem icon={Calendar} title="Events" tone="indigo">
Pick one or more events. Every report loads data for exactly these events.
</GuideItem>
<GuideItem icon={EyeOff} title="Include past / inactive / closed events" tone="gray">
Controls which events even appear in the Events list to pick from.
</GuideItem>
<GuideItem icon={CalendarClock} title="Date range" tone="blue">
A preset (This month, Last month, This year) or a custom range. Only applies to reports that are inherently date-based (e.g. Payments between dates, Revenue reports, Cashup audit trail) reports like Attendees or Ticket usage show a live snapshot and ignore the date range.
</GuideItem>
</div>
</div>
)}
{tab === "specific" && (
<div className="space-y-4">
<p>Some reports have extra options that only make sense for that report these appear at the top of the report popup once it&apos;s open, separate from the universal filters.</p>
<div className="space-y-4">
<GuideItem icon={ListFilter} title="Attendees" tone="violet">
Which single event to show (defaults to the first selected event) and whether to include cancelled registrations.
</GuideItem>
<GuideItem icon={BarChart2} title="Registration status breakdown" tone="emerald">
Whether to include cancelled registrations in the counts, and whether to count by number of registrations or by ticket quantity (so someone with 3 tickets counts as 3).
</GuideItem>
<GuideItem icon={RefreshCw} title="Refresh" tone="indigo">
Adjust a report-specific filter, then use the &quot;Refresh&quot; button inside the popup to re-run the report without closing it.
</GuideItem>
</div>
</div>
)}
{tab === "exporting" && (
<div className="space-y-4">
<p>Every report can be exported straight from its popup:</p>
<div className="space-y-4">
<GuideItem icon={Printer} title="Print" tone="gray">
Opens a print-ready PDF in a new tab; use your browser&apos;s print button from there.
</GuideItem>
<GuideItem icon={Mail} title="Email" tone="blue">
Sends the PDF to your own account email.
</GuideItem>
<GuideItem icon={FileSpreadsheet} title="Excel" tone="emerald">
Downloads a styled .xlsx workbook colored header, key totals, and a chart section where available matching the on-screen report.
</GuideItem>
<GuideItem icon={MessageCircle} title="WhatsApp" tone="violet">
Sends the PDF to your own account&apos;s WhatsApp number (needs a valid phone number on file).
</GuideItem>
</div>
</div>
)}
{tab === "fields" && (
<div className="space-y-4">
<p>A few terms come up across several financial reports and are easy to misread here&apos;s what each one actually means:</p>
<div className="space-y-4">
<GuideItem icon={CreditCard} title="Paid" tone="blue">
Money the person paid themselves directly (cash/card/eft/online). Never includes money that reached their order via someone else&apos;s donation.
</GuideItem>
<GuideItem icon={Gift} title="Paid via donation" tone="violet">
The portion of an order that was covered by an assigned donation. This is part of what&apos;s &quot;settled&quot; on the order, but it&apos;s the donor&apos;s money, not the registrant&apos;s so it&apos;s broken out separately and attributed to the donor elsewhere in the report.
</GuideItem>
<GuideItem icon={Clock} title="Outstanding" tone="amber">
What&apos;s still owed on an order, after direct payments and any donation cover.
</GuideItem>
<GuideItem icon={HandHeart} title="Unassigned donations" tone="rose">
Real money already received as a donation that hasn&apos;t been applied to any order yet.
</GuideItem>
<GuideItem icon={BarChart2} title="Donations: Used / Unused" tone="emerald">
How much of a given donation has been assigned to orders (Used) versus what&apos;s still available to assign (Unused). A donation is never overwritten when assigned the original donation record always keeps its full original amount.
</GuideItem>
</div>
</div>
)}
{tab === "help" && (
<div className="space-y-4">
<p>Still stuck? Reach out to the site administrator they can check the underlying data with you or flag anything that looks wrong.</p>
<div className="flex items-start gap-3 border border-gray-100 rounded-xl p-4 bg-gray-50">
<div className="w-9 h-9 rounded-full bg-indigo-50 flex items-center justify-center shrink-0">
<Mail className="w-4 h-4 text-indigo-600" />
</div>
<div>
<div className="font-medium text-gray-800">Site administrator</div>
<a href={`mailto:${ADMIN_EMAIL}`} className="text-sm text-indigo-600 hover:underline">{ADMIN_EMAIL}</a>
</div>
</div>
<p className="text-xs text-gray-500">Financial figures matter if a number in a report doesn&apos;t look right, it&apos;s always worth asking rather than assuming.</p>
</div>
)}
</div>
</div>
<div className="flex items-center justify-between px-5 py-3 border-t">
<label className="flex items-center gap-2 text-xs text-gray-600 cursor-pointer">
<input type="checkbox" checked={dontShowAgain} onChange={e => setDontShowAgain(e.target.checked)} />
Don&apos;t show this again
</label>
<button className="px-4 py-2 text-sm rounded-lg bg-indigo-600 text-white hover:bg-indigo-700" onClick={() => onClose(dontShowAgain)}>
Got it
</button>
</div>
</div>
</div>
</div>
);
}
-533
View File
@@ -1,533 +0,0 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { downloadCsv, mailtoReport, openPrintWindow } from "@/lib/export";
// Common small UI controls
function Section({ title, children, actions }: { title: string; children: React.ReactNode; actions?: React.ReactNode }) {
return (
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
<div className="flex items-center justify-between mb-3">
<div className="text-lg font-semibold">{title}</div>
<div className="flex gap-2">{actions}</div>
</div>
{children}
</div>
);
}
function MultiSelect({ options, value, onChange, className }: { options: { value: string; label: string }[]; value: string[]; onChange: (v: string[]) => void; className?: string }) {
const toggle = (v: string) => {
const set = new Set(value);
if (set.has(v)) set.delete(v); else set.add(v);
onChange(Array.from(set));
};
return (
<div className={"flex flex-wrap gap-2 " + (className || '')}>
{options.map(opt => (
<label key={opt.value} className={"px-2 py-1 text-xs rounded border cursor-pointer " + (value.includes(opt.value) ? "bg-indigo-600 text-white border-indigo-600" : "bg-white text-gray-800 border-gray-200") }>
<input type="checkbox" className="hidden" checked={value.includes(opt.value)} onChange={() => toggle(opt.value)} />
{opt.label}
</label>
))}
</div>
);
}
export default function Reports() {
const { token, user } = useAuth();
const role = user?.role || 'user';
const canView = role === 'admin' || role === 'supervisor';
const [events, setEvents] = useState<any[]>([]);
const [loadingEvents, setLoadingEvents] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
(async () => {
try {
setLoadingEvents(true);
const evs = await apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token || undefined });
setEvents(Array.isArray(evs) ? evs : []);
} catch (e: any) {
setError(e?.message || 'Failed to load events');
} finally {
setLoadingEvents(false);
}
})();
}, [token, role]);
// Filters state
const [dateFrom, setDateFrom] = useState<string>("");
const [dateTo, setDateTo] = useState<string>("");
const [selectedEvents, setSelectedEvents] = useState<string[]>([]);
const [attendeesEventId, setAttendeesEventId] = useState<string>("");
const [includeCancelled, setIncludeCancelled] = useState<boolean>(false);
const [includePastEvents, setIncludePastEvents] = useState<boolean>(false);
// DATA
const [paymentsByEvent, setPaymentsByEvent] = useState<Record<string, any[]>>({});
const [registrationsByEvent, setRegistrationsByEvent] = useState<Record<string, any[]>>({});
const [loading, setLoading] = useState(false);
// Load initial selected events
useEffect(() => {
if (events.length > 0 && selectedEvents.length === 0) {
const nowIso = new Date().toISOString();
const filtered = events.filter(ev => includePastEvents || !ev.endDate || new Date(ev.endDate).toISOString() >= nowIso);
const ids = filtered.map(ev => ev.id);
setSelectedEvents(ids.slice(0, Math.min(ids.length, 3))); // pick first few by default
if (!attendeesEventId && ids.length > 0) setAttendeesEventId(ids[0]);
}
}, [events, includePastEvents, attendeesEventId]);
// Fetch payments per selected event (works for staff via /event/:id, and for supervisor/admin we could also use this)
const loadPayments = async (eventIds: string[]) => {
if (!token) return;
setLoading(true);
try {
const byEv: Record<string, any[]> = {};
for (const id of eventIds) {
try {
const list = await apiFetch<any[]>(`/api/payments/event/${encodeURIComponent(id)}`, { authToken: token });
byEv[id] = Array.isArray(list) ? list : [];
} catch (e) {
byEv[id] = [];
}
}
setPaymentsByEvent(byEv);
} finally {
setLoading(false);
}
};
// Fetch registrations for selected/attendees events
const loadRegistrations = async (eventIds: string[]) => {
if (!token) return;
setLoading(true);
try {
const byEv: Record<string, any[]> = {};
for (const id of eventIds) {
try {
const list = await apiFetch<any[]>(`/api/registrations/event/${encodeURIComponent(id)}`, { authToken: token });
byEv[id] = Array.isArray(list) ? list : [];
} catch (e) {
byEv[id] = [];
}
}
setRegistrationsByEvent(byEv);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (selectedEvents.length > 0) {
loadPayments(selectedEvents);
loadRegistrations(selectedEvents);
}
}, [token, selectedEvents]);
// Helpers
const paymentsRows = useMemo(() => {
// Flatten to rows applying date filter, grouped by event then user in presentation
const df = dateFrom ? new Date(dateFrom).getTime() : null;
const dt = dateTo ? new Date(dateTo).getTime() : null;
const rows: { eventId: string; eventTitle: string; userName: string; userEmail?: string; amount: number; method: string; isDonation?: boolean; createdAt: string; registrationId?: string }[] = [];
for (const evId of Object.keys(paymentsByEvent)) {
const ev = events.find(e => e.id === evId);
const evTitle = ev?.title || evId;
for (const p of paymentsByEvent[evId] || []) {
const t = new Date(p.createdAt).getTime();
if ((df && t < df) || (dt && t > dt)) continue;
// For reporting: if it's a donation, show the payer; otherwise show the user assigned to the registration
const user = p.isDonation ? (p.user || {}) : ((p.registration?.user || p.user) || {});
rows.push({
eventId: evId,
eventTitle: evTitle,
userName: user?.name || user?.email || p.userId || 'User',
userEmail: user?.email,
amount: p.amount,
method: p.method,
isDonation: p.isDonation,
createdAt: p.createdAt,
registrationId: p.registrationId || undefined,
});
}
}
// Sort by event, then user, then date
rows.sort((a,b) => (a.eventTitle.localeCompare(b.eventTitle) || (a.userName || '').localeCompare(b.userName || '') || new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()));
return rows;
}, [paymentsByEvent, dateFrom, dateTo, events]);
const attendeesRows = useMemo(() => {
const evId = attendeesEventId;
const regs = registrationsByEvent[evId] || [];
const filtered = regs.filter((r: any) => includeCancelled ? true : (r.status !== 'cancelled'));
const rows = filtered.map((r: any) => ({
name: r.user?.name || r.userId,
email: r.user?.email,
status: r.status,
options: (r.registrationOptions || []).map((ro: any) => `${ro.eventOption?.name || 'Option'} x${ro.quantity}`).join('; '),
registeredAt: r.createdAt,
}));
rows.sort((a: any, b: any) => (a.name || '').localeCompare(b.name || ''));
return rows;
}, [registrationsByEvent, attendeesEventId, includeCancelled]);
const regTypeCounts = useMemo(() => {
// For selectedEvents gather counts of eventOption.name quantities
const counts: Record<string, Record<string, number>> = {}; // eventId -> optionName -> qty
for (const evId of selectedEvents) {
const regs = registrationsByEvent[evId] || [];
counts[evId] = counts[evId] || {};
for (const r of regs) {
if (r.status === 'cancelled') continue; // default exclude
for (const ro of (r.registrationOptions || [])) {
const name = ro.eventOption?.name || 'Option';
const qty = ro.quantity || 0;
counts[evId][name] = (counts[evId][name] || 0) + qty;
}
}
}
return counts;
}, [registrationsByEvent, selectedEvents]);
// Actions for each report
const exportPaymentsCsv = () => {
downloadCsv(`payments_${new Date().toISOString().slice(0,10)}`, paymentsRows.map(r => ({
Event: r.eventTitle,
User: r.userName,
Email: r.userEmail || '',
Amount: r.amount,
Method: r.method,
Donation: r.isDonation ? 'Yes' : 'No',
Date: new Date(r.createdAt).toLocaleString()
})));
};
const printPayments = () => {
const html = tableHtml(['Event','User','Email','Amount','Method','Donation','Date'], paymentsRows.map(r => [
r.eventTitle,
r.userName,
r.userEmail || '',
String(r.amount),
r.method,
r.isDonation ? 'Yes' : 'No',
new Date(r.createdAt).toLocaleString()
]));
openPrintWindow('Payments Report', html);
};
const emailPayments = () => {
const summary = `Payments report generated on ${new Date().toLocaleString()}\nFilters: from ${dateFrom || '-'} to ${dateTo || '-'}; Events: ${selectedEvents.length}`;
mailtoReport('Payments Report', summary + '\n\nPlease find the CSV/PDF attached.');
};
const exportAttendeesCsv = () => {
downloadCsv(`attendees_${attendeesEventId}_${new Date().toISOString().slice(0,10)}`, attendeesRows.map(r => ({
Name: r.name,
Email: r.email || '',
Status: r.status,
Options: r.options,
RegisteredAt: new Date(r.registeredAt).toLocaleString()
})));
};
const printAttendees = () => {
const html = tableHtml(['Name','Email','Status','Options','Registered At'], attendeesRows.map(r => [r.name, r.email || '', r.status, r.options, new Date(r.registeredAt).toLocaleString()]));
openPrintWindow('Attendees Report', html);
};
const emailAttendees = () => {
const ev = events.find(e => e.id === attendeesEventId);
const summary = `Attendees for ${ev?.title || attendeesEventId} generated on ${new Date().toLocaleString()}\nInclude cancelled: ${includeCancelled ? 'Yes' : 'No'}`;
mailtoReport('Attendees Report', summary + '\n\nPlease find the CSV/PDF attached.');
};
const exportRegTypesCsv = () => {
const rows: any[] = [];
for (const evId of Object.keys(regTypeCounts)) {
const ev = events.find(e => e.id === evId);
const byType = regTypeCounts[evId];
for (const type of Object.keys(byType)) rows.push({ Event: ev?.title || evId, Type: type, Quantity: byType[type] });
}
downloadCsv(`registration_types_${new Date().toISOString().slice(0,10)}`, rows);
};
const printRegTypes = () => {
const rows: string[][] = [];
for (const evId of Object.keys(regTypeCounts)) {
const ev = events.find(e => e.id === evId);
const byType = regTypeCounts[evId];
for (const type of Object.keys(byType)) rows.push([ev?.title || evId, type, String(byType[type])]);
}
const html = tableHtml(['Event','Type','Quantity'], rows);
openPrintWindow('Registration Types Report', html);
};
const emailRegTypes = () => {
const summary = `Registration types report generated on ${new Date().toLocaleString()} for ${Object.keys(regTypeCounts).length} event(s).`;
mailtoReport('Registration Types Report', summary + '\n\nPlease find the CSV/PDF attached.');
};
// Extra useful report: Ticket usage summary per event (Used vs Unused, requires ticket scans info already available via tickets API on staff pages)
const [ticketUsage, setTicketUsage] = useState<Record<string, { used: number; unused: number }>>({});
const loadTicketUsage = async (eventIds: string[]) => {
if (!token) return;
const result: Record<string, { used: number; unused: number }> = {};
for (const id of eventIds) {
try {
const tickets = await apiFetch<any[]>(`/api/tickets/event/${encodeURIComponent(id)}`, { authToken: token });
const used = tickets.filter(t => t.isUsed).length;
const unused = tickets.filter(t => !t.isUsed).length;
result[id] = { used, unused };
} catch (e) {
result[id] = { used: 0, unused: 0 };
}
}
setTicketUsage(result);
};
useEffect(() => { if (selectedEvents.length) loadTicketUsage(selectedEvents); }, [token, selectedEvents]);
const exportUsageCsv = () => {
const rows: any[] = [];
for (const evId of Object.keys(ticketUsage)) {
const ev = events.find(e => e.id === evId);
rows.push({ Event: ev?.title || evId, Used: ticketUsage[evId].used, Unused: ticketUsage[evId].unused });
}
downloadCsv(`ticket_usage_${new Date().toISOString().slice(0,10)}`, rows);
};
const printUsage = () => {
const rows: string[][] = [];
for (const evId of Object.keys(ticketUsage)) {
const ev = events.find(e => e.id === evId);
const tu = ticketUsage[evId];
rows.push([ev?.title || evId, String(tu.used), String(tu.unused)]);
}
const html = tableHtml(['Event','Used','Unused'], rows);
openPrintWindow('Ticket Usage Report', html);
};
const emailUsage = () => {
const summary = `Ticket usage report generated on ${new Date().toLocaleString()} for ${Object.keys(ticketUsage).length} event(s).`;
mailtoReport('Ticket Usage Report', summary + '\n\nPlease find the CSV/PDF attached.');
};
// UI
return (
<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 view reports.
</div>
)}
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
<div className="text-sm font-medium mb-2">Global filters</div>
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">From</label>
<input type="date" className="border rounded px-3 py-2 text-sm" value={dateFrom} onChange={e => setDateFrom(e.target.value)} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">To</label>
<input type="date" className="border rounded px-3 py-2 text-sm" value={dateTo} onChange={e => setDateTo(e.target.value)} />
</div>
<div className="flex-1 min-w-64">
<label className="block text-xs text-gray-600 mb-1">Select events</label>
<MultiSelect
options={events.map(ev => ({ value: ev.id, label: ev.title }))}
value={selectedEvents}
onChange={setSelectedEvents}
/>
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={includePastEvents} onChange={e => setIncludePastEvents(e.target.checked)} /> Include past events in list
</label>
<button onClick={() => { loadPayments(selectedEvents); loadRegistrations(selectedEvents); }} className="px-3 py-2 text-sm rounded bg-gray-100 hover:bg-gray-200">Refresh data</button>
</div>
</div>
<Section
title="Payments between dates (grouped by event then user)"
actions={
<>
<button className="px-3 py-1.5 text-sm rounded border" onClick={exportPaymentsCsv}>Export CSV</button>
<button className="px-3 py-1.5 text-sm rounded border" onClick={printPayments}>Save as PDF</button>
<button className="px-3 py-1.5 text-sm rounded border" onClick={emailPayments}>Email</button>
</>
}
>
{loading && <div className="text-sm text-gray-500 mb-2">Loading</div>}
<div className="overflow-auto">
<table className="min-w-[640px]">
<thead>
<tr>
<th>Event</th>
<th>User</th>
<th>Email</th>
<th>Amount</th>
<th>Method</th>
<th>Donation</th>
<th>Date</th>
</tr>
</thead>
<tbody>
{paymentsRows.length === 0 ? (
<tr><td colSpan={7} className="text-sm text-gray-500">No payments match the selected filters.</td></tr>
) : paymentsRows.map((r, idx) => (
<tr key={idx}>
<td>{r.eventTitle}</td>
<td>{r.userName}</td>
<td>{r.userEmail || ''}</td>
<td>R {Number(r.amount).toFixed(2)}</td>
<td>{r.method}</td>
<td>{r.isDonation ? 'Yes' : 'No'}</td>
<td>{new Date(r.createdAt).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</Section>
<Section
title="Attendees per event and registration status"
actions={
<>
<button className="px-3 py-1.5 text-sm rounded border" onClick={exportAttendeesCsv}>Export CSV</button>
<button className="px-3 py-1.5 text-sm rounded border" onClick={printAttendees}>Save as PDF</button>
<button className="px-3 py-1.5 text-sm rounded border" onClick={emailAttendees}>Email</button>
</>
}
>
<div className="flex flex-wrap items-center gap-3 mb-3">
<label className="text-sm">
<span className="text-gray-600 mr-2">Event</span>
<select className="border rounded px-3 py-2 text-sm" value={attendeesEventId} onChange={e => setAttendeesEventId(e.target.value)}>
{events.map(ev => (<option key={ev.id} value={ev.id}>{ev.title}</option>))}
</select>
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={includeCancelled} onChange={e => setIncludeCancelled(e.target.checked)} /> Include cancelled registrations
</label>
<button className="px-3 py-1.5 text-sm rounded border" onClick={() => loadRegistrations([attendeesEventId])}>Refresh</button>
</div>
<div className="overflow-auto">
<table className="min-w-[640px]">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Status</th>
<th>Options</th>
<th>Registered at</th>
</tr>
</thead>
<tbody>
{attendeesRows.length === 0 ? (
<tr><td colSpan={5} className="text-sm text-gray-500">No attendees for selected filters.</td></tr>
) : attendeesRows.map((r, idx) => (
<tr key={idx}>
<td>{r.name}</td>
<td>{r.email || ''}</td>
<td className="capitalize">{r.status}</td>
<td>{r.options}</td>
<td>{new Date(r.registeredAt).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</Section>
<Section
title="Registration type counts per event"
actions={
<>
<button className="px-3 py-1.5 text-sm rounded border" onClick={exportRegTypesCsv}>Export CSV</button>
<button className="px-3 py-1.5 text-sm rounded border" onClick={printRegTypes}>Save as PDF</button>
<button className="px-3 py-1.5 text-sm rounded border" onClick={emailRegTypes}>Email</button>
</>
}
>
<div className="overflow-auto">
<table className="min-w-[480px]">
<thead>
<tr>
<th>Event</th>
<th>Registration Type</th>
<th>Quantity</th>
</tr>
</thead>
<tbody>
{Object.keys(regTypeCounts).length === 0 ? (
<tr><td colSpan={3} className="text-sm text-gray-500">No data available. Select events and refresh.</td></tr>
) : (
Object.keys(regTypeCounts).flatMap(evId => {
const ev = events.find(e => e.id === evId);
const byType = regTypeCounts[evId] || {};
const rows = Object.keys(byType);
if (rows.length === 0) return [<tr key={evId}><td>{ev?.title || evId}</td><td colSpan={2} className="text-sm text-gray-500">No registrations</td></tr>];
return rows.map(type => (
<tr key={evId + type}>
<td>{ev?.title || evId}</td>
<td>{type}</td>
<td>{byType[type]}</td>
</tr>
));
})
)}
</tbody>
</table>
</div>
</Section>
<Section
title="Ticket usage summary (extra)"
actions={
<>
<button className="px-3 py-1.5 text-sm rounded border" onClick={exportUsageCsv}>Export CSV</button>
<button className="px-3 py-1.5 text-sm rounded border" onClick={printUsage}>Save as PDF</button>
<button className="px-3 py-1.5 text-sm rounded border" onClick={emailUsage}>Email</button>
</>
}
>
<div className="overflow-auto">
<table className="min-w-[420px]">
<thead>
<tr>
<th>Event</th>
<th>Used</th>
<th>Unused</th>
</tr>
</thead>
<tbody>
{Object.keys(ticketUsage).length === 0 ? (
<tr><td colSpan={3} className="text-sm text-gray-500">No data available. Select events and refresh.</td></tr>
) : Object.keys(ticketUsage).map(evId => {
const ev = events.find(e => e.id === evId);
const tu = ticketUsage[evId];
return (
<tr key={evId}>
<td>{ev?.title || evId}</td>
<td>{tu.used}</td>
<td>{tu.unused}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Section>
</div>
);
}
function tableHtml(headers: string[], rows: (string | number)[][]) {
const thead = `<tr>${headers.map(h => `<th>${escapeHtml(h)}</th>`).join('')}</tr>`;
const tbody = rows.map(r => `<tr>${r.map(c => `<td>${escapeHtml(String(c ?? ''))}</td>`).join('')}</tr>`).join('');
return `<table><thead>${thead}</thead><tbody>${tbody}</tbody></table>`;
}
function escapeHtml(s: string) {
return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c] as string));
}
@@ -1,7 +1,7 @@
"use client";
import React, { useMemo, useState } from "react";
import { ArrowLeft, HelpCircle, Search } from "lucide-react";
import { ArrowLeft, Search } from "lucide-react";
import { REPORTS, REPORT_CATEGORIES, type ReportKey, type ReportCategory } from "./ReportCatalog";
import EventsDropdown from "./EventsDropdown";
@@ -17,7 +17,6 @@ export default function ReportsShell({
dateFrom, setDateFrom, dateTo, setDateTo,
report, setReport,
onViewReport, busy,
onOpenGuide,
onBack,
}: {
events: EventLite[]; isAdmin: boolean;
@@ -28,7 +27,6 @@ export default function ReportsShell({
dateFrom: string; setDateFrom: (v: string) => void; dateTo: string; setDateTo: (v: string) => void;
report: ReportKey; setReport: (r: ReportKey) => void;
onViewReport: () => void; busy: boolean;
onOpenGuide: () => void;
onBack?: () => void;
}) {
const [search, setSearch] = useState("");
@@ -141,14 +139,6 @@ export default function ReportsShell({
)}
</div>
</div>
<button type="button" className="w-full flex items-start gap-3 text-left text-sm border rounded-xl p-4 bg-white shadow-sm hover:bg-gray-50" onClick={onOpenGuide}>
<HelpCircle className="w-5 h-5 text-gray-500 shrink-0" />
<span>
<span className="block font-medium text-gray-800">Need help?</span>
<span className="block text-xs text-gray-500">View our reporting guide</span>
</span>
</button>
</aside>
{/* Main: categories, report grid */}
@@ -158,7 +148,7 @@ export default function ReportsShell({
<button
key={c}
type="button"
className={"px-3 py-1.5 text-sm rounded-lg border " + (category === c ? "bg-indigo-600 text-white border-indigo-600" : "bg-white text-gray-700 border-gray-200 hover:bg-gray-50")}
className={"px-3 py-1.5 text-sm rounded-lg border " + (category === c ? "bg-brand-600 text-white border-brand-600" : "bg-white text-gray-700 border-gray-200 hover:bg-gray-50")}
onClick={() => setCategory(c)}
>
{c}
@@ -175,7 +165,7 @@ export default function ReportsShell({
key={r.key}
type="button"
onClick={() => setReport(r.key)}
className={"text-left border rounded-xl p-4 transition " + (selected ? "border-indigo-500 ring-2 ring-indigo-100 bg-indigo-50/40" : "border-gray-200 hover:border-gray-300 bg-white")}
className={"text-left border rounded-xl p-4 transition " + (selected ? "border-brand-500 ring-2 ring-brand-100 bg-brand-50/40" : "border-gray-200 hover:border-gray-300 bg-white")}
>
<div className="w-9 h-9 rounded-lg bg-gray-100 flex items-center justify-center mb-3">
<Icon className="w-5 h-5 text-gray-600" />
@@ -199,7 +189,7 @@ export default function ReportsShell({
<button
disabled={busy}
onClick={onViewReport}
className="px-4 py-2 text-sm rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50"
className="px-4 py-2 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50"
>
{busy ? "Loading…" : "View report"}
</button>
+43 -24
View File
@@ -1,6 +1,7 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, API_BASE } from "@/lib/api";
import { downloadReportExcel, emailReportPdf, whatsappReportPdf, viewReportPdf, type ReportPdfPayload } from "@/lib/export";
@@ -8,7 +9,6 @@ import { Printer, Mail, FileSpreadsheet, MessageCircle, ShoppingCart, CreditCard
import { REPORTS, type ReportKey } from "./ReportCatalog";
import ReportsShell from "./ReportsShell";
import ReportViewerModal, { ReportActionButton } from "./ReportViewerModal";
import ReportingGuideModal, { GUIDE_DISMISSED_KEY } from "./ReportingGuideModal";
import { StatTile, StatTileRow } from "./StatTile";
import { HorizontalBarChart } from "./charts/HorizontalBarChart";
@@ -91,6 +91,7 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
const { token, user } = useAuth();
const role = user?.role || "user";
const canView = role === "admin" || role === "supervisor";
const searchParams = useSearchParams();
// Events list
const [events, setEvents] = useState<any[]>([]);
@@ -135,23 +136,6 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
const [ready, setReady] = useState(false); // toggled by View button
const [busy, setBusy] = useState(false);
const [popupOpen, setPopupOpen] = useState(false);
const [guideOpen, setGuideOpen] = useState(false);
// Show the reporting guide automatically on first visit, unless the user dismissed it for good.
useEffect(() => {
try {
if (typeof window !== "undefined" && window.localStorage.getItem(GUIDE_DISMISSED_KEY) !== "1") {
setGuideOpen(true);
}
} catch {}
}, []);
const closeGuide = (dontShowAgain: boolean) => {
setGuideOpen(false);
if (dontShowAgain) {
try { window.localStorage.setItem(GUIDE_DISMISSED_KEY, "1"); } catch {}
}
};
// Universal filters — apply to every report's onView loader (per-report filters below stay
// report-specific). A single events selection replaces what used to be ~10 separate
@@ -190,10 +174,48 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
const [financialsByEvent, setFinancialsByEvent] = useState<Record<string, any>>({});
const [auditRows, setAuditRows] = useState<any[]>([]);
// Deep-linking: dashboards link here with ?report=<key>&range=trailing_month to jump
// straight into a specific report, pre-filtered to every currently-visible event and the
// trailing month (today back one month, matching the dashboard KPI window — see
// trailingMonthRanges in backend/src/controllers/statsController.js), instead of landing
// on the plain report grid. deepLinkHandledRef is a ref (not state) so it updates
// synchronously — the "Initialize default selection" effect right below reads it in the
// same commit to skip its own single-event default.
const deepLinkHandledRef = useRef(false);
const [autoViewPending, setAutoViewPending] = useState(false);
useEffect(() => {
if (deepLinkHandledRef.current) return;
if (filteredEvents.length === 0) return;
const reportParam = searchParams.get("report");
if (!reportParam || !REPORTS.some(r => r.key === reportParam)) return;
deepLinkHandledRef.current = true;
setReport(reportParam as ReportKey);
setSelectedEventIds(filteredEvents.map((e: any) => e.id));
if (searchParams.get("range") === "trailing_month") {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 30);
const iso = (d: Date) => d.toISOString().slice(0, 10);
setDateFrom(iso(start));
setDateTo(iso(now));
}
setPopupOpen(true);
setAutoViewPending(true);
}, [filteredEvents, searchParams]);
// Fires once the state set above has actually committed (selectedEventIds reflects the
// deep-link's full event list), so onView() below closes over the updated values instead
// of the stale defaults from the render that scheduled it.
useEffect(() => {
if (!autoViewPending || selectedEventIds.length === 0) return;
setAutoViewPending(false);
onView();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoViewPending, selectedEventIds, report]);
// Initialize default selection when events load
useEffect(() => {
if (filteredEvents.length === 0) return;
if (selectedEventIds.length === 0) setSelectedEventIds(filteredEvents.slice(0, 1).map((e: any) => e.id));
if (!deepLinkHandledRef.current && selectedEventIds.length === 0) setSelectedEventIds(filteredEvents.slice(0, 1).map((e: any) => e.id));
if (!attEventId) setAttEventId(filteredEvents[0].id);
}, [filteredEvents]);
@@ -1213,12 +1235,9 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
report={report} setReport={(r: ReportKey) => { setReport(r); setReady(false); }}
onViewReport={() => { setPopupOpen(true); onView(); }}
busy={busy}
onOpenGuide={() => setGuideOpen(true)}
onBack={onBack}
/>
{guideOpen && <ReportingGuideModal onClose={closeGuide} />}
{popupOpen && (
<ReportViewerModal
title={activeReportMeta?.label || "Report"}
@@ -1697,7 +1716,7 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
<td className="py-2 px-3 text-right">R {masterTotals?.paidViaDonation.toFixed(2)}</td>
<td className="py-2 px-3 text-right">R {masterTotals?.outstanding.toFixed(2)}</td>
</tr>
<tr className="text-xs bg-blue-50/60">
<tr className="text-xs bg-brand-50/60">
<td className="py-1.5 px-3" colSpan={3}>Revenue per Ticket</td>
{masterOptions.map(opt => (
File diff suppressed because it is too large Load Diff
-37
View File
@@ -1,37 +0,0 @@
import React from "react";
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "secondary" | "outline" | "danger";
loading?: boolean;
};
export function Button({
variant = "primary",
loading = false,
className = "",
children,
disabled,
...rest
}: ButtonProps) {
const base = "inline-flex items-center justify-center rounded px-4 py-2 text-sm transition-colors";
const variants: Record<string, string> = {
primary: "bg-blue-600 text-white hover:bg-blue-700",
secondary: "bg-gray-800 text-white hover:bg-black/90",
outline: "border border-gray-300 text-gray-800 hover:bg-gray-50",
danger: "bg-red-600 text-white hover:bg-red-700",
};
const cls = [base, variants[variant], disabled || loading ? "opacity-60 cursor-not-allowed" : "", className]
.filter(Boolean)
.join(" ");
return (
<button className={cls} disabled={disabled || loading} {...rest}>
{loading && (
<svg className="mr-2 h-4 w-4 animate-spin" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
</svg>
)}
{children}
</button>
);
}
@@ -1,41 +0,0 @@
"use client";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { SectionHeader } from "@/components/shared/SectionHeader";
export function ContactSection() {
const { settings } = useSiteSettings();
const email = settings.org_email || "";
const phone = settings.org_phone || "";
const address = settings.org_address || "";
if (!email && !phone && !address) return null;
return (
<section id="contact" className="bg-gray-100 py-12 px-4 text-center">
<SectionHeader title="Contact Us" />
{email && (
<p className="text-gray-700 mb-2">
📧{" "}
<a href={`mailto:${email}`} className="hover:underline">
{email}
</a>
</p>
)}
{phone && (
<p className="text-gray-700 mb-2">
📞{" "}
<a href={`tel:${phone}`} className="hover:underline">
{phone}
</a>
</p>
)}
{address && (
<p className="text-gray-700">
📍 {address}
</p>
)}
</section>
);
}
@@ -0,0 +1,19 @@
import React from "react";
import type { LucideIcon } from "lucide-react";
import { TONES, type Tone } from "./tones";
/** A labeled icon + description row, used inside help-guide tab content. */
export function GuideItem({ icon: Icon, title, children, tone = "gray" }: { icon: LucideIcon; title: string; children: React.ReactNode; tone?: Tone }) {
const t = TONES[tone] || TONES.gray;
return (
<div className="flex items-start gap-3">
<div className={"w-8 h-8 rounded-full flex items-center justify-center shrink-0 " + t.bg}>
<Icon className={"w-4 h-4 " + t.icon} />
</div>
<div>
<div className="font-medium text-gray-800">{title}</div>
<div className="text-xs text-gray-500 mt-0.5">{children}</div>
</div>
</div>
);
}
@@ -0,0 +1,45 @@
"use client";
import React from "react";
import { usePathname } from "next/navigation";
import { HelpCircle } from "lucide-react";
import { useHelpGuide } from "@/hooks/useHelpGuide";
import { resolveHelpContent } from "@/content/help/registry";
import HelpGuideModal from "./HelpGuideModal";
/**
* The site's single help entry point a floating round button, present on
* every page (mounted once in the root layout). Content is resolved from
* the current route via content/help/registry.ts, falling back to general
* baseline content (how to register / view tickets) for any page without a
* dedicated guide yet.
*/
// Unattended/operator-driven screens where a floating help button would just
// get in the way (kiosk touchscreen, first-run setup wizard) — this hook
// still runs, it just renders nothing there.
const EXCLUDED_PREFIXES = ["/self-service", "/setup"];
export default function HelpFab() {
const pathname = usePathname() || "/";
const { slug, content } = resolveHelpContent(pathname);
const { open, openGuide, closeGuide } = useHelpGuide(slug);
if (EXCLUDED_PREFIXES.some(prefix => pathname.startsWith(prefix))) {
return null;
}
return (
<>
<button
type="button"
onClick={openGuide}
aria-label="Help"
// bottom-20 on mobile clears BottomNav (fixed, h-16 + safe-area, z-50).
className="fixed z-40 bottom-20 md:bottom-6 right-4 md:right-6 w-12 h-12 rounded-full bg-brand-600 text-white shadow-lg hover:bg-brand-700 hover:scale-105 hover:shadow-xl active:scale-95 flex items-center justify-center transition-all"
>
<HelpCircle className="w-6 h-6" />
</button>
{open && <HelpGuideModal content={content} onClose={closeGuide} />}
</>
);
}
@@ -0,0 +1,107 @@
"use client";
import React, { useState } from "react";
import Link from "next/link";
import { X, Mail, MessageCircleQuestion } from "lucide-react";
import type { HelpContent } from "@/content/help/types";
export default function HelpGuideModal({ content, onClose }: { content: HelpContent; onClose: (dontShowAgain: boolean) => void }) {
const [tab, setTab] = useState(content.tabs[0]?.key);
const [dontShowAgain, setDontShowAgain] = useState(false);
const activeTab = content.tabs.find(t => t.key === tab) || content.tabs[0];
return (
// Above the site header (Navbar is `sticky top-0 z-50`) and above any
// page-level popup (e.g. the report viewer at z-[60]), since the guide
// can be opened from the FAB while another overlay is showing.
<div className="fixed inset-0 z-[70]">
<div className="absolute inset-0 bg-black/40 animate-in fade-in duration-200" onClick={() => onClose(dontShowAgain)} />
<div className="absolute inset-0 flex items-center justify-center p-4">
<div
className="w-full max-w-3xl bg-white rounded-2xl shadow-2xl animate-in fade-in zoom-in-95 slide-in-from-bottom-2 duration-200 overflow-hidden"
onClick={e => e.stopPropagation()}
>
<div className="flex items-start justify-between px-5 py-4 border-b bg-gradient-to-r from-brand-50/60 to-white">
<div className="flex items-start gap-3">
<div className="w-9 h-9 rounded-full bg-brand-50 flex items-center justify-center shrink-0">
<MessageCircleQuestion className="w-5 h-5 text-brand-600" />
</div>
<div>
<h2 className="text-base font-semibold text-gray-900">{content.title}</h2>
<p className="text-xs text-gray-500">{content.subtitle}</p>
</div>
</div>
<button className="p-1.5 rounded-full hover:bg-gray-100 transition-colors" onClick={() => onClose(dontShowAgain)} aria-label="Close">
<X className="w-5 h-5" />
</button>
</div>
{content.quickLinks && content.quickLinks.length > 0 && (
<div className="flex flex-wrap gap-2 px-5 py-3 border-b bg-gray-50">
{content.quickLinks.map(link => (
<Link
key={link.href}
href={link.href}
onClick={() => onClose(dontShowAgain)}
className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-full border border-brand-200 bg-white text-brand-700 hover:bg-brand-100 hover:border-brand-300 transition-colors"
>
{link.icon && <link.icon className="w-3.5 h-3.5" />}
{link.label}
</Link>
))}
</div>
)}
<div className="flex flex-col sm:flex-row">
{content.tabs.length > 1 && (
<nav className="sm:w-56 shrink-0 border-b sm:border-b-0 sm:border-r p-3 space-y-1 bg-gray-50/50">
{content.tabs.map(t => {
const Icon = t.icon;
const active = activeTab?.key === t.key;
return (
<button
key={t.key}
type="button"
onClick={() => setTab(t.key)}
className={"w-full flex items-center gap-2 text-left text-sm px-3 py-2 rounded-lg transition-colors " + (active ? "bg-brand-600 text-white font-medium shadow-sm" : "text-gray-600 hover:bg-white hover:shadow-sm")}
>
<Icon className="w-4 h-4 shrink-0" />
{t.label}
</button>
);
})}
</nav>
)}
<div className="flex-1 min-w-0 p-5 text-sm text-gray-700 max-h-[60vh] overflow-auto">
{activeTab?.content}
</div>
</div>
<div className="border-t px-5 py-3 space-y-2 bg-gray-50/50">
{content.supportContact && (
<div className="flex items-center gap-2 text-xs text-gray-500">
<Mail className="w-3.5 h-3.5 shrink-0" />
<span>
{content.supportContact.label}:{" "}
<a href={`mailto:${content.supportContact.email}`} className="text-brand-600 hover:underline">
{content.supportContact.email}
</a>
</span>
</div>
)}
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 text-xs text-gray-600 cursor-pointer">
<input type="checkbox" checked={dontShowAgain} onChange={e => setDontShowAgain(e.target.checked)} className="rounded accent-brand-600" />
Don&apos;t show this again
</label>
<button className="px-4 py-2 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700 transition-colors shadow-sm" onClick={() => onClose(dontShowAgain)}>
Got it
</button>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,41 @@
"use client";
import React from "react";
import Link from "next/link";
import type { LucideIcon } from "lucide-react";
import { TONES, type Tone } from "./tones";
/** A single "quick action" tile — icon chip, title, description, whole tile links out. */
export function QuickActionTile({
icon: Icon,
title,
description,
href,
tone = "brand",
}: {
icon: LucideIcon;
title: string;
description: string;
href: string;
tone?: Tone;
}) {
const t = TONES[tone] || TONES.brand;
return (
<Link
href={href}
className="flex items-start gap-3 rounded-xl border border-gray-200 bg-white p-4 shadow-sm hover:border-brand-200 hover:shadow transition-shadow"
>
<div className={"w-9 h-9 rounded-lg flex items-center justify-center shrink-0 " + t.bg}>
<Icon className={"w-5 h-5 " + t.icon} />
</div>
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-900">{title}</div>
<div className="text-xs text-gray-500 mt-0.5">{description}</div>
</div>
</Link>
);
}
export function QuickActionGrid({ children }: { children: React.ReactNode }) {
return <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">{children}</div>;
}
@@ -1,3 +0,0 @@
export const SectionHeader = ({ title }: { title: string }) => (
<h2 className="text-2xl font-semibold mb-6 text-center">{title}</h2>
);
@@ -0,0 +1,71 @@
"use client";
import React from "react";
import Link from "next/link";
import { ArrowRight, ArrowUp, ArrowDown, type LucideIcon } from "lucide-react";
import { TONES, type Tone } from "./tones";
/** A "vs last month"-style delta line. `value` is a percentage; null means no baseline to compare against (shown as "New"). */
export type StatDelta = { value: number | null; label?: string };
/**
* Richer stat tile for dashboard KPI rows (icon chip + label + big value +
* optional delta + optional "View X" link) scaffolding for Phase 2's
* dashboard rebuilds. Not the same component as reports/StatTile.tsx, which
* stays as-is for Reports' own denser tiles.
*/
export function StatCard({
icon: Icon,
label,
value,
tone = "brand",
href,
linkLabel,
delta,
}: {
icon: LucideIcon;
label: string;
value: string | number;
tone?: Tone;
href?: string;
linkLabel?: string;
delta?: StatDelta;
}) {
const t = TONES[tone] || TONES.brand;
return (
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
<div className="flex items-center gap-3">
<div className={"w-10 h-10 rounded-lg flex items-center justify-center shrink-0 " + t.bg}>
<Icon className={"w-5 h-5 " + t.icon} />
</div>
<div className="min-w-0">
<div className="text-xs text-gray-500 truncate">{label}</div>
<div className="text-xl font-semibold text-gray-900 truncate">{value}</div>
</div>
</div>
{delta && (
<div className={"mt-2 flex items-center gap-1 text-xs font-medium " + (delta.value == null ? "text-gray-400" : delta.value >= 0 ? "text-emerald-600" : "text-rose-600")}>
{delta.value == null ? (
<span>New this month</span>
) : (
<>
{delta.value >= 0 ? <ArrowUp className="w-3 h-3" /> : <ArrowDown className="w-3 h-3" />}
<span>{Math.abs(delta.value).toFixed(0)}%</span>
<span className="text-gray-400 font-normal">{delta.label || "vs last month"}</span>
</>
)}
</div>
)}
{href && (
<Link href={href} className="mt-3 inline-flex items-center gap-1 text-xs font-medium text-brand-600 hover:underline">
{linkLabel || "View"}
<ArrowRight className="w-3 h-3" />
</Link>
)}
</div>
);
}
export function StatCardRow({ children }: { children: React.ReactNode }) {
return <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">{children}</div>;
}
@@ -0,0 +1,23 @@
import React from "react";
function initialsFrom(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
return parts
.slice(0, 2)
.map(p => p[0]!.toUpperCase())
.join("");
}
export function UserAvatar({ name, className = "" }: { name: string; className?: string }) {
return (
<div
className={
"shrink-0 rounded-full bg-brand-600 text-white flex items-center justify-center font-semibold " +
(className || "w-10 h-10 text-sm")
}
>
{initialsFrom(name)}
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
/**
* Centralized pastel icon-chip tone map used by stat/quick-action/help
* components (StatCard, QuickActionTile, HelpGuideModal, HelpFab, etc.).
* Mirrors the tone maps already used by reports/StatTile.tsx and the
* retired ReportingGuideModal new components should use this shared copy
* rather than hand-rolling another one.
*/
export const TONES = {
green: { bg: "bg-emerald-50", icon: "text-emerald-600" },
blue: { bg: "bg-blue-50", icon: "text-blue-600" },
brand: { bg: "bg-brand-50", icon: "text-brand-600" },
violet: { bg: "bg-violet-50", icon: "text-violet-600" },
amber: { bg: "bg-amber-50", icon: "text-amber-600" },
rose: { bg: "bg-rose-50", icon: "text-rose-600" },
gray: { bg: "bg-gray-100", icon: "text-gray-600" },
} as const;
export type Tone = keyof typeof TONES;