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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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 "Refresh" 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'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'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'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'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's "settled" on the order, but it's the donor's money, not the registrant's — so it's broken out separately and attributed to the donor elsewhere in the report.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Clock} title="Outstanding" tone="amber">
|
||||
What'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'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'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't look right, it'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|
||||
|
||||
@@ -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
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user