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
221 lines
6.8 KiB
TypeScript
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)}
|
|
</>
|
|
);
|
|
}
|