RJBULK-APP/components/shell/mobile-nav.tsx
SJCoetz 1efa979443 feat: initial setup of rj bulk supplies backoffice app
Adds complete initial codebase for the RJ Bulk Supplies fuel delivery backoffice:
- Authentication pages and protected route layouts
- Customer, supplier, fuel purchase, and delivery note management
- Custom reporting and customer statement sending tools
- Full UI component library and Tailwind styling
- Server actions, API routes, and PocketBase integration
- Development scripts and project configuration updates
- Updated README and package metadata
2026-07-19 20:38:16 +02:00

221 lines
6.8 KiB
TypeScript

"use client";
import { useState, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Menu, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
LayoutDashboard,
Users,
Tag,
Package,
Banknote,
Truck,
Fuel,
Send,
BarChart3,
ScrollText,
Settings,
} from "lucide-react";
import { cn } from "@/lib/utils";
type NavItem = {
href: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
};
const groups: { label: string; items: NavItem[] }[] = [
{
label: "Overview",
items: [{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard }],
},
{
label: "Operations",
items: [
{ href: "/customers", label: "Customers", icon: Users },
{ href: "/delivery-notes", label: "Delivery Notes", icon: Package },
{ href: "/cash-customers", label: "Cash Customers", icon: Banknote },
{ href: "/suppliers", label: "Suppliers", icon: Truck },
{ href: "/fuel-purchases", label: "Fuel Purchases", icon: Fuel },
],
},
{
label: "Pricing & reports",
items: [
{ href: "/pricing", label: "Pricing", icon: Tag },
{ href: "/send-statement", label: "Send Statement", icon: Send },
{ href: "/custom-reports", label: "Custom Reports", icon: BarChart3 },
{ href: "/statements", label: "Statement Log", icon: ScrollText },
],
},
{
label: "Admin",
items: [{ href: "/settings", label: "Settings", icon: Settings }],
},
];
export function MobileNav() {
const [open, setOpen] = useState(false);
const [rendered, setRendered] = useState(false);
const [mounted, setMounted] = useState(false);
const pathname = usePathname();
const triggerRef = useRef<HTMLButtonElement>(null);
// Track mount so we only portal on the client (avoids SSR mismatch)
useEffect(() => {
setMounted(true);
}, []);
// Close the menu whenever the route changes
useEffect(() => {
setOpen(false);
}, [pathname]);
// Keep the sheet mounted briefly after closing so the exit animation plays
useEffect(() => {
if (open) {
setRendered(true);
return;
}
const t = setTimeout(() => setRendered(false), 250);
return () => clearTimeout(t);
}, [open]);
// Lock body scroll while open
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open]);
// Close on Escape, restore focus to the trigger
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") {
setOpen(false);
triggerRef.current?.focus();
}
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
const trigger = (
<Button
ref={triggerRef}
variant="ghost"
size="icon"
className="md:hidden -ml-2"
onClick={() => setOpen(true)}
aria-label="Open navigation"
aria-expanded={open}
>
<Menu className="h-5 w-5" />
</Button>
);
if (!rendered || !mounted) {
return trigger;
}
const sheet = (
<>
{/* Backdrop — fades in/out */}
<div
className={cn(
"fixed inset-0 z-[100] bg-black/50 md:hidden transition-opacity duration-200 ease-out",
open ? "opacity-100" : "opacity-0 pointer-events-none"
)}
onClick={() => setOpen(false)}
aria-hidden
/>
{/* Sheet — slides in from the left.
Rendered via portal at document.body so it escapes any containing
block created by parent transforms/filters (e.g. backdrop-blur). */}
<aside
className={cn(
"fixed inset-y-0 left-0 z-[101] w-72 max-w-[85vw] h-screen bg-sidebar text-sidebar-foreground border-r border-sidebar-border md:hidden flex flex-col shadow-2xl",
"transition-transform duration-250 ease-out",
open ? "translate-x-0" : "-translate-x-full"
)}
style={{ transitionDuration: "250ms" }}
role="dialog"
aria-label="Navigation"
aria-modal="true"
aria-hidden={!open}
>
<div className="flex h-16 items-center justify-between border-b border-sidebar-border px-4">
<div className="flex items-center gap-2.5">
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-sidebar-primary text-sidebar-primary-foreground font-bold text-sm">
RJ
</div>
<div className="flex flex-col leading-tight">
<span className="text-sm font-semibold">RJ Bulk Supplies</span>
<span className="text-[11px] text-muted-foreground">Back office</span>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setOpen(false)}
aria-label="Close navigation"
className="-mr-2"
>
<X className="h-5 w-5" />
</Button>
</div>
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-5">
{groups.map((group, gi) => (
<div key={group.label}>
{gi > 0 && <div className="border-t border-sidebar-border mb-4" />}
<div className="px-3 mb-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-primary/80">
{group.label}
</div>
<div className="space-y-0.5">
{group.items.map((item) => {
const Icon = item.icon;
const active = pathname === item.href || (item.href !== "/" && pathname.startsWith(item.href + "/"));
return (
<Link
key={item.href}
href={item.href}
tabIndex={open ? 0 : -1}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2.5 text-sm transition-colors",
active
? "bg-sidebar-accent text-sidebar-accent-foreground font-medium"
: "hover:bg-sidebar-accent/50",
group.label === "Admin" && !active && "text-muted-foreground"
)}
>
<Icon className={cn("h-4 w-4", active && "text-primary")} />
{item.label}
</Link>
);
})}
</div>
</div>
))}
</nav>
<div className="px-4 py-3 border-t border-sidebar-border text-[10px] text-muted-foreground">
RJ Bulk v1.0
</div>
</aside>
</>
);
return (
<>
{trigger}
{createPortal(sheet, document.body)}
</>
);
}