fix: branded 404, dropdown trigger, chart YAxis overflow, SSR hydration (intl), TZ off-by-one

This commit is contained in:
SJCoetz 2026-07-21 21:38:47 +02:00
parent 4a18b8789e
commit 31d67f5796
6 changed files with 288 additions and 63 deletions

View file

@ -1,7 +1,7 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { pbList } from "@/lib/pocketbase";
import { getAuth } from "@/lib/auth";
import { formatCurrency, formatDate, formatNumber } from "@/lib/utils";
import { formatCurrency, formatDate, formatDateInput, formatNumber } from "@/lib/utils";
import { Fuel, Users, Receipt, TrendingUp, ArrowUpRight, Plus, FileText, Truck, BarChart3, Send, UserPlus } from "lucide-react";
import { DELIVERY_STATUSES, type DeliveryStatus, type FuelPurchase, type DeliveryNote, type Customer } from "@/lib/types";
import { DashboardChartsSection } from "@/components/dashboard-charts-section";
@ -12,12 +12,16 @@ async function loadDashboard(token: string) {
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const startOfLastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const endOfLastMonth = new Date(now.getFullYear(), now.getMonth(), 0);
const sixMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 5, 1);
const sixMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 5, 1).toISOString();
const startOfMonthStr = startOfMonth.toISOString().slice(0, 10);
const startOfLastMonthStr = startOfLastMonth.toISOString().slice(0, 10);
const endOfLastMonthStr = endOfLastMonth.toISOString().slice(0, 10);
// Use LOCAL date components (not toISOString) so the filter string doesn't
// shift back a day in positive UTC offsets. In SAST (UTC+2),
// `new Date(2026, 6, 1).toISOString().slice(0,10)` returns "2026-06-30",
// which would silently pull June 30 into the "this month" total.
const startOfMonthStr = formatDateInput(startOfMonth);
const startOfLastMonthStr = formatDateInput(startOfLastMonth);
const endOfLastMonthStr = formatDateInput(endOfLastMonth);
const sixMonthsAgoStr = formatDateInput(sixMonthsAgo);
const [
monthRes, lastMonthRes, pendingRes, customersRes,
@ -32,8 +36,8 @@ async function loadDashboard(token: string) {
pbList<DeliveryNote>("delivery_notes", { perPage: 5000, filter: `date >= "${startOfLastMonthStr}" && date <= "${endOfLastMonthStr}"`, fields: "customer,qty_litres_supplied,total_value" }, token),
pbList<Customer>("customers", { perPage: 500, fields: "id,name" }, token),
pbList<DeliveryNote>("delivery_notes", { perPage: 1000, filter: `status = "Pending" || status = ""`, fields: "status" }, token),
pbList<DeliveryNote>("delivery_notes", { perPage: 5000, filter: `date >= "${sixMonthsAgo}"`, sort: "date" }, token),
pbList<FuelPurchase>("fuel_purchases", { perPage: 5000, filter: `purchase_date >= "${sixMonthsAgo}"`, sort: "purchase_date" }, token),
pbList<DeliveryNote>("delivery_notes", { perPage: 5000, filter: `date >= "${sixMonthsAgoStr}"`, sort: "date" }, token),
pbList<FuelPurchase>("fuel_purchases", { perPage: 5000, filter: `purchase_date >= "${sixMonthsAgoStr}"`, sort: "purchase_date" }, token),
]);
const sum = (items: any[], key: string) => items.reduce((a, n) => a + (n[key] || 0), 0);
@ -133,11 +137,13 @@ function firstName(name: string | undefined, email: string | undefined): string
}
function todayLabel(): string {
return new Date().toLocaleDateString("en-ZA", {
weekday: "long",
day: "numeric",
month: "long",
});
// Avoid `toLocaleDateString` — Node and browser ICU disagree on en-ZA and
// produce different text (one says "Tuesday, 21 July", the other "Tue 21
// Jul"). Hard-code the strings so SSR and client render the same bytes.
const d = new Date();
const weekdays = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
const months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
return `${weekdays[d.getDay()]}, ${d.getDate()} ${months[d.getMonth()]}`;
}
export default async function DashboardPage() {

109
app/not-found.tsx Normal file
View file

@ -0,0 +1,109 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { BackButton } from "@/components/back-button";
import { getAuth } from "@/lib/auth";
import { Fuel, Home, FileText, Users, Truck } from "lucide-react";
/**
* Branded 404 — sits outside the (auth)/(authenticated) route groups so it works
* whether or not the user is signed in. Renders inside the global root layout
* (Outfit font, app background, theme variables).
*/
export default async function NotFound() {
// Best-effort auth probe — if signed in, the quick links go to the dashboard
// and the in-app pages; if not, they go to /login.
const { user } = await getAuth();
const homeHref = user ? "/dashboard" : "/login";
const links = user
? [
{ href: "/dashboard", label: "Dashboard", icon: Home },
{ href: "/delivery-notes", label: "Delivery notes", icon: FileText },
{ href: "/customers", label: "Customers", icon: Users },
{ href: "/suppliers", label: "Suppliers", icon: Truck },
]
: [
{ href: "/login", label: "Sign in", icon: Home },
];
return (
<div className="relative min-h-screen overflow-hidden bg-muted/40">
{/* Subtle background — brand orange radial glow, no flashy gradient */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-primary/10 via-transparent to-transparent"
/>
<div className="relative mx-auto flex min-h-screen max-w-2xl flex-col items-center justify-center px-6 py-16 text-center">
{/* Brand mark — same as the PDF header and sidebar logo */}
<div className="mb-8 flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-md bg-primary text-primary-foreground">
<Fuel className="h-6 w-6" />
</div>
<div className="text-left">
<div className="text-sm font-semibold tracking-tight">RJ Bulk Supplies</div>
<div className="text-xs text-muted-foreground">Fuel delivery specialists</div>
</div>
</div>
{/* Big 404 — the number is the hero, but the rest of the page balances it */}
<div className="relative">
<div
aria-hidden
className="select-none text-[10rem] font-bold leading-none tracking-tighter text-primary/15 sm:text-[14rem]"
>
404
</div>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">
We can&apos;t find that page
</h1>
<p className="mt-2 max-w-md text-sm text-muted-foreground sm:text-base">
The link may be broken, or the page may have been moved. If you got here
from inside the app, please report it — something probably needs fixing.
</p>
</div>
</div>
{/* Primary actions */}
<div className="mt-10 flex flex-wrap items-center justify-center gap-3">
<Button asChild size="lg">
<Link href={homeHref}>
<Home className="h-4 w-4" />
{user ? "Back to dashboard" : "Go to sign in"}
</Link>
</Button>
<BackButton />
</div>
{/* Quick links — useful when the user mistyped a deep URL */}
<div className="mt-12 w-full max-w-md">
<div className="mb-3 text-xs font-medium uppercase tracking-[0.14em] text-muted-foreground">
Or jump to
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{links.map((l) => {
const Icon = l.icon;
return (
<Link
key={l.href}
href={l.href}
className="group flex flex-col items-center gap-1.5 rounded-md border bg-background px-3 py-3 text-xs transition-colors hover:border-primary/40 hover:bg-primary/5"
>
<Icon className="h-4 w-4 text-muted-foreground group-hover:text-primary" />
<span className="font-medium">{l.label}</span>
</Link>
);
})}
</div>
</div>
<div className="mt-12 text-xs text-muted-foreground">
Need help? Reach out at{" "}
<a className="text-primary underline-offset-4 hover:underline" href="mailto:accounts@rjbulk.co.za">
accounts@rjbulk.co.za
</a>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,21 @@
"use client";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
/**
* Tiny client component for the 404 "Go back" button. Kept separate so the
* NotFound page itself can stay a Server Component (it calls getAuth()).
*/
export function BackButton() {
return (
<Button
variant="outline"
size="lg"
onClick={() => history.back()}
>
<ArrowLeft className="h-4 w-4" />
Go back
</Button>
);
}

View file

@ -40,6 +40,63 @@ const STATUS_COLORS: Record<string, string> = {
"CARD": COLORS.secondary,
};
/**
* Compact litre axis tick formatter — keeps YAxis labels short so they don't
* overflow the chart's left edge on narrow viewports. Recharts auto-picks
* round tick values, so a max of 382,935 would otherwise render a "450000L"
* label that escapes the chart.
*/
function formatLitreTick(v: number): string {
if (v === 0) return "0";
const abs = Math.abs(v);
if (abs >= 1_000_000) return `${(v / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
if (abs >= 1_000) return `${(v / 1_000).toFixed(v % 1000 === 0 ? 0 : 1).replace(/\.0$/, "")}k`;
return String(v);
}
/**
* One column of the "This month vs last month" stat row. Drop decimals, drop
* the "L" suffix from the main number (move it to the unit span), shrink to
* text-xl, and use tabular-nums so the three columns line up. This keeps the
* row from overflowing at half-width and from breaking on hydration.
*/
function StatCell({
label,
value,
muted = false,
signed = false,
tone = "default",
}: {
label: string;
value: number;
muted?: boolean;
signed?: boolean;
tone?: "default" | "primary" | "positive";
}) {
const toneClass =
tone === "positive"
? "text-emerald-600"
: tone === "primary"
? "text-primary"
: muted
? "text-muted-foreground"
: "text-foreground";
const sign = signed && value > 0 ? "+" : "";
// No decimals on the headline number — "342 707 L" is plenty of precision
// for a month-level stat and the decimals were causing the columns to
// overflow at md breakpoint.
const int = Math.round(value);
return (
<div className="min-w-0">
<div className={`text-xl font-semibold tabular-nums truncate ${toneClass}`}>
{sign}
{formatNumber(int, 0)} <span className="text-xs font-normal text-muted-foreground">L</span>
</div>
<div className="text-xs text-muted-foreground truncate">{label}</div>
</div>
);
}
interface DailyPoint {
date: string;
label: string;
@ -104,7 +161,8 @@ export function DailyDeliveriesChart({ data }: { data: DailyPoint[] }) {
<YAxis
tick={{ fontSize: 11 }}
className="fill-muted-foreground"
tickFormatter={(v) => `${v}L`}
width={56}
tickFormatter={formatLitreTick}
/>
<Tooltip
contentStyle={{
@ -173,7 +231,8 @@ export function MonthlyDeliveryPerCustomerChart({ data }: { data: MonthlyPerCust
<YAxis
tick={{ fontSize: 11 }}
className="fill-muted-foreground"
tickFormatter={(v) => `${v}L`}
width={56}
tickFormatter={formatLitreTick}
/>
<Tooltip
contentStyle={{
@ -293,23 +352,15 @@ export function MonthVsLastChart({ data }: { data: MonthComparison }) {
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-2xl font-semibold">{formatNumber(thisMonth)} L</div>
<div className="text-xs text-muted-foreground">This month</div>
</div>
<div>
<div className="text-2xl font-semibold text-muted-foreground">{formatNumber(lastMonth)} L</div>
<div className="text-xs text-muted-foreground">Last month</div>
</div>
<div>
<div className={`text-2xl font-semibold ${beating ? "text-emerald-600" : "text-primary"}`}>
{beating ? "+" : ""}{formatNumber(thisMonth - lastMonth)} L
</div>
<div className="text-xs text-muted-foreground">
{beating ? "ahead" : "behind"}
</div>
</div>
<div className="grid grid-cols-3 gap-3 text-center">
<StatCell label="This month" value={thisMonth} />
<StatCell label="Last month" value={lastMonth} muted />
<StatCell
label={beating ? "ahead" : "behind"}
value={thisMonth - lastMonth}
tone={beating ? "positive" : "primary"}
signed
/>
</div>
<div className="space-y-2">
@ -352,14 +403,14 @@ export function MonthVsLastChart({ data }: { data: MonthComparison }) {
<ComposedChart
layout="vertical"
data={chartData}
margin={{ top: 5, right: 30, left: 70, bottom: 0 }}
margin={{ top: 5, right: 30, left: 80, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis
type="number"
tick={{ fontSize: 11 }}
className="fill-muted-foreground"
tickFormatter={(v) => `${v}L`}
tickFormatter={formatLitreTick}
/>
<YAxis
type="category"
@ -479,7 +530,12 @@ export function PurchasesVsDeliveriesChart({ data }: { data: MonthPoint[] }) {
<ComposedChart data={data} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="label" tick={{ fontSize: 11 }} className="fill-muted-foreground" />
<YAxis tick={{ fontSize: 11 }} className="fill-muted-foreground" tickFormatter={(v) => `${v}L`} />
<YAxis
tick={{ fontSize: 11 }}
className="fill-muted-foreground"
width={56}
tickFormatter={formatLitreTick}
/>
<Tooltip
contentStyle={{
background: "hsl(var(--popover))",

View file

@ -11,7 +11,8 @@ import {
} from "@/components/ui/dropdown-menu";
import { LogOut, User as UserIcon, Moon, Sun } from "lucide-react";
import { logoutAction } from "@/actions/auth";
import { Button } from "@/components/ui/button";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { MobileNav } from "./mobile-nav";
interface TopBarProps {
@ -27,6 +28,10 @@ export function TopBar({ user }: TopBarProps) {
localStorage.setItem("theme", html.classList.contains("dark") ? "dark" : "light");
}
// Render the trigger as a plain button (no asChild + Slot). The asChild +
// Slot + Radix Trigger chain is fragile under React 19 — the click handler
// doesn't always toggle the menu even though data-state is set. A direct
// button is reliable and gets the same shadcn styling via buttonVariants().
return (
<header className="sticky top-0 z-20 flex h-16 items-center justify-between gap-2 border-b bg-background/95 px-4 md:px-6 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex items-center gap-2">
@ -37,19 +42,32 @@ export function TopBar({ user }: TopBarProps) {
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={toggleDark} aria-label="Toggle theme">
<button
type="button"
onClick={toggleDark}
aria-label="Toggle theme"
className={cn(buttonVariants({ variant: "ghost", size: "icon" }), "relative")}
>
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
</Button>
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-semibold">
{(user.name || user.email).charAt(0).toUpperCase()}
</div>
<span className="hidden sm:inline text-sm">{user.name || user.email}</span>
</Button>
<DropdownMenuTrigger
className={cn(
buttonVariants({ variant: "ghost", size: "sm" }),
"gap-2 px-2"
)}
>
<span
aria-hidden
className="flex h-7 w-7 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-semibold"
>
{(user.name || user.email).charAt(0).toUpperCase()}
</span>
<span className="hidden sm:inline text-sm font-medium">
{user.name || user.email}
</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>

View file

@ -5,39 +5,54 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatCurrency(value: number | null | undefined): string {
if (value == null) return "R0.00";
return new Intl.NumberFormat("en-ZA", {
style: "currency",
currency: "ZAR",
minimumFractionDigits: 2,
}).format(value);
/**
* Deterministic number formatters.
*
* We deliberately avoid `Intl.NumberFormat("en-ZA", …)` because Node and the
* browser ship different ICU builds — on the server, `en-ZA` falls back to a
* generic locale and renders "342,707.17", while the browser renders the
* expected South African "342 707,17". That mismatch blows up SSR hydration
* (https://react.dev/link/hydration-mismatch). Hard-coding the format keeps
* server and client byte-identical.
*
* Style: space as thousand separator, comma as decimal point, exactly the
* shape `Intl` produces in a real en-ZA browser. Always positive.
*/
function groupThousands(intPart: string): string {
// Add a space every 3 digits from the right.
return intPart.replace(/\B(?=(\d{3})+(?!\d))/g, " ");
}
export function formatNumber(value: number | null | undefined, decimals = 2): string {
if (value == null) return "0";
return new Intl.NumberFormat("en-ZA", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(value);
if (value == null || isNaN(value)) return "0";
// toFixed gives a stable, locale-independent string. Split on the decimal
// point so we can rejoin with a comma.
const [intPart, fracPart] = value.toFixed(decimals).split(".");
const sign = intPart.startsWith("-") ? "-" : "";
const digits = sign ? intPart.slice(1) : intPart;
const grouped = groupThousands(digits);
return fracPart != null ? `${sign}${grouped},${fracPart}` : `${sign}${grouped}`;
}
export function formatCurrency(value: number | null | undefined): string {
if (value == null || isNaN(value)) return "R0,00";
return `R ${formatNumber(value, 2)}`;
}
export function formatDate(value: string | Date | null | undefined): string {
if (!value) return "";
const d = typeof value === "string" ? new Date(value) : value;
if (isNaN(d.getTime())) return "";
return new Intl.DateTimeFormat("en-ZA", {
year: "numeric",
month: "short",
day: "2-digit",
}).format(d);
// "21 Jul 2026" — stable across server and client.
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
return `${String(d.getDate()).padStart(2, "0")} ${months[d.getMonth()]} ${d.getFullYear()}`;
}
export function formatDateInput(value: string | Date | null | undefined): string {
if (!value) return "";
const d = typeof value === "string" ? new Date(value) : value;
if (isNaN(d.getTime())) return "";
// YYYY-MM-DD for <input type="date">
// YYYY-MM-DD for <input type="date"> — already locale-neutral.
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");