diff --git a/app/(authenticated)/dashboard/page.tsx b/app/(authenticated)/dashboard/page.tsx index 44c3048d..51eb33f0 100644 --- a/app/(authenticated)/dashboard/page.tsx +++ b/app/(authenticated)/dashboard/page.tsx @@ -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("delivery_notes", { perPage: 5000, filter: `date >= "${startOfLastMonthStr}" && date <= "${endOfLastMonthStr}"`, fields: "customer,qty_litres_supplied,total_value" }, token), pbList("customers", { perPage: 500, fields: "id,name" }, token), pbList("delivery_notes", { perPage: 1000, filter: `status = "Pending" || status = ""`, fields: "status" }, token), - pbList("delivery_notes", { perPage: 5000, filter: `date >= "${sixMonthsAgo}"`, sort: "date" }, token), - pbList("fuel_purchases", { perPage: 5000, filter: `purchase_date >= "${sixMonthsAgo}"`, sort: "purchase_date" }, token), + pbList("delivery_notes", { perPage: 5000, filter: `date >= "${sixMonthsAgoStr}"`, sort: "date" }, token), + pbList("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() { diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 00000000..ff63379e --- /dev/null +++ b/app/not-found.tsx @@ -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 ( +
+ {/* Subtle background — brand orange radial glow, no flashy gradient */} +
+ +
+ {/* Brand mark — same as the PDF header and sidebar logo */} +
+
+ +
+
+
RJ Bulk Supplies
+
Fuel delivery specialists
+
+
+ + {/* Big 404 — the number is the hero, but the rest of the page balances it */} +
+
+ 404 +
+
+

+ We can't find that page +

+

+ 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. +

+
+
+ + {/* Primary actions */} +
+ + +
+ + {/* Quick links — useful when the user mistyped a deep URL */} +
+
+ Or jump to +
+
+ {links.map((l) => { + const Icon = l.icon; + return ( + + + {l.label} + + ); + })} +
+
+ +
+ Need help? Reach out at{" "} + + accounts@rjbulk.co.za + +
+
+
+ ); +} diff --git a/components/back-button.tsx b/components/back-button.tsx new file mode 100644 index 00000000..8055e145 --- /dev/null +++ b/components/back-button.tsx @@ -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 ( + + ); +} diff --git a/components/dashboard-charts.tsx b/components/dashboard-charts.tsx index ccc266a8..af3211f0 100644 --- a/components/dashboard-charts.tsx +++ b/components/dashboard-charts.tsx @@ -40,6 +40,63 @@ const STATUS_COLORS: Record = { "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 ( +
+
+ {sign} + {formatNumber(int, 0)} L +
+
{label}
+
+ ); +} + interface DailyPoint { date: string; label: string; @@ -104,7 +161,8 @@ export function DailyDeliveriesChart({ data }: { data: DailyPoint[] }) { `${v}L`} + width={56} + tickFormatter={formatLitreTick} /> `${v}L`} + width={56} + tickFormatter={formatLitreTick} /> -
-
-
{formatNumber(thisMonth)} L
-
This month
-
-
-
{formatNumber(lastMonth)} L
-
Last month
-
-
-
- {beating ? "+" : ""}{formatNumber(thisMonth - lastMonth)} L -
-
- {beating ? "ahead" : "behind"} -
-
+
+ + +
@@ -352,14 +403,14 @@ export function MonthVsLastChart({ data }: { data: MonthComparison }) { `${v}L`} + tickFormatter={formatLitreTick} /> - `${v}L`} /> +
@@ -37,19 +42,32 @@ export function TopBar({ user }: TopBarProps) {
- + - - + + + {(user.name || user.email).charAt(0).toUpperCase()} + + + {user.name || user.email} + diff --git a/lib/utils.ts b/lib/utils.ts index 18ab92c4..e4555a17 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -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 + // YYYY-MM-DD for — already locale-neutral. const yyyy = d.getFullYear(); const mm = String(d.getMonth() + 1).padStart(2, "0"); const dd = String(d.getDate()).padStart(2, "0");