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>
104 lines
4.3 KiB
TypeScript
104 lines
4.3 KiB
TypeScript
"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>
|
|
);
|
|
}
|