fix: branded 404, dropdown trigger, chart YAxis overflow, SSR hydration (intl), TZ off-by-one
This commit is contained in:
parent
4a18b8789e
commit
31d67f5796
6 changed files with 288 additions and 63 deletions
|
|
@ -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
109
app/not-found.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
21
components/back-button.tsx
Normal file
21
components/back-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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))",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
51
lib/utils.ts
51
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 <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");
|
||||
|
|
|
|||
Loading…
Reference in a new issue