WFDASH-WEBAPP/src/components/sidebar.tsx

366 lines
12 KiB
TypeScript
Raw Normal View History

"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useAuth } from "./auth-provider";
import { useTheme } from "next-themes";
import type { LucideIcon } from "lucide-react";
import {
LayoutDashboard,
Users,
Building2,
Truck,
FileText,
ShoppingCart,
LogOut,
Sun,
Moon,
Menu,
CheckCircle,
Archive,
Package,
ClipboardList,
Wrench,
MapPinned,
MapPin,
Plus,
Settings as SettingsIcon,
Tag,
Zap,
ChevronDown,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Sheet, SheetContent, SheetTrigger, SheetTitle } from "@/components/ui/sheet";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
/* ============================================================================
* Sidebar nav structure
*
* Six flat sections. The active state is the theme's single accent.
* No per-category brand colours — every section reads the same so the eye
* scans label + icon, not colour.
* ========================================================================== */
type NavEntry = {
href: string;
label: string;
icon: LucideIcon;
};
const dashboardItems: NavEntry[] = [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
];
const managementItems: NavEntry[] = [
{ href: "/companies", label: "Companies", icon: Building2 },
{ href: "/customers", label: "Customers", icon: Users },
{ href: "/suppliers", label: "Suppliers", icon: Truck },
{ href: "/supplier-map", label: "Supplier Map", icon: MapPinned },
{ href: "/pricelist", label: "Pricelist", icon: Tag },
];
const quoteItems: NavEntry[] = [
{ href: "/quotes", label: "Quote List", icon: FileText },
{ href: "/sprint", label: "Sprint", icon: Zap },
{ href: "/quotes/approvals", label: "Pending Approvals", icon: CheckCircle },
{ href: "/quotes/archived", label: "Archived", icon: Archive },
];
const orderItems: NavEntry[] = [
{ href: "/orders/new", label: "New Order", icon: Plus },
{ href: "/orders", label: "Order List", icon: ShoppingCart },
{ href: "/orders/assign", label: "Assign Drivers", icon: Truck },
];
const operationsItems: NavEntry[] = [
{ href: "/collection-pool", label: "Collection Pool", icon: Package },
{ href: "/collection-map", label: "Collection Map", icon: MapPin },
{ href: "/delivery-notes", label: "Delivery Notes", icon: ClipboardList },
{ href: "/repairs", label: "Repairs", icon: Wrench },
];
/* ============================================================================
* Active-route resolution
* The naïve "exact OR starts-with-prefix" rule double-highlights
* `/orders/new` and `/orders`. We fix it by demanding the item is the
* most-specific match within its group.
* ========================================================================== */
function isActive(itemHref: string, siblings: NavEntry[], pathname: string): boolean {
if (pathname === itemHref) return true;
if (!pathname.startsWith(itemHref + "/")) return false;
return !siblings.some(
(other) =>
other.href !== itemHref &&
other.href.length > itemHref.length &&
(pathname === other.href || pathname.startsWith(other.href + "/"))
);
}
/* ============================================================================
* NavSection — collapsible group with a small uppercase label
* ========================================================================== */
function NavSection({
label,
items,
pathname,
defaultOpen = false,
onLinkClick,
}: {
label: string;
items: NavEntry[];
pathname: string;
defaultOpen?: boolean;
onLinkClick?: () => void;
}) {
const sectionActive = items.some(
(i) => pathname === i.href || pathname.startsWith(i.href + "/")
);
const [open, setOpen] = useState(defaultOpen || sectionActive);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
if (sectionActive) setOpen(true);
}, [sectionActive]);
return (
<div className="space-y-0.5">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center gap-1.5 px-2.5 py-1 text-[10.5px] font-semibold uppercase tracking-widest text-muted-foreground hover:text-foreground transition-colors"
>
{label}
<ChevronDown
className={cn(
"ml-auto h-3 w-3 transition-transform",
open ? "" : "-rotate-90"
)}
/>
</button>
{open && (
<div className="space-y-0.5">
{items.map((item) => (
<NavLink
key={item.href}
item={item}
active={isActive(item.href, items, pathname)}
onClick={onLinkClick}
/>
))}
</div>
)}
</div>
);
}
/* ============================================================================
* NavLink — single nav row, theme-driven active state
* ========================================================================== */
function NavLink({
item,
active,
onClick,
}: {
item: NavEntry;
active: boolean;
onClick?: () => void;
}) {
return (
<Link
href={item.href}
onClick={onClick}
className={cn(
"group flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors",
active
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground"
)}
>
<item.icon
className={cn(
"h-[17px] w-[17px] shrink-0",
active ? "text-primary" : "text-sidebar-foreground/60 group-hover:text-sidebar-foreground/80"
)}
aria-hidden
/>
<span className="truncate">{item.label}</span>
{active && (
<span className="ml-auto h-1.5 w-1.5 rounded-full bg-primary" aria-hidden />
)}
</Link>
);
}
/* ============================================================================
* NavContent — the actual sidebar body
* ========================================================================== */
function NavContent({ onLinkClick }: { onLinkClick?: () => void }) {
const pathname = usePathname();
const { user, logout } = useAuth();
const { theme, setTheme } = useTheme();
return (
<div className="flex h-full flex-col bg-sidebar text-sidebar-foreground">
{/* Brand block */}
<Link
href="/dashboard"
onClick={onLinkClick}
className="flex items-center gap-3 px-5 pt-6 pb-5"
>
<div className="grid size-9 place-items-center rounded-lg bg-primary text-primary-foreground">
<Package className="h-4.5 w-4.5" />
</div>
<div className="leading-tight">
<div className="text-[15px] font-semibold tracking-tight font-heading-theme">
WFDASH
</div>
<div className="text-[11px] text-muted-foreground">
Westcott Fasteners
</div>
</div>
</Link>
<Separator className="mx-3" />
{/* Nav */}
<ScrollArea className="flex-1 px-3 py-3">
<nav className="space-y-3">
{/* Dashboard — single, top-level */}
<NavLink
item={dashboardItems[0]}
active={isActive(dashboardItems[0].href, dashboardItems, pathname)}
onClick={onLinkClick}
/>
<Separator className="mx-1" />
<NavSection
label="Management"
items={managementItems}
pathname={pathname}
onLinkClick={onLinkClick}
/>
<NavSection
label="Quotes"
items={quoteItems}
pathname={pathname}
onLinkClick={onLinkClick}
/>
<NavSection
label="Orders"
items={orderItems}
pathname={pathname}
onLinkClick={onLinkClick}
/>
<NavSection
label="Operations"
items={operationsItems}
pathname={pathname}
onLinkClick={onLinkClick}
/>
</nav>
</ScrollArea>
<Separator className="mx-3" />
{/* Bottom block */}
<div className="space-y-1 p-3">
<Link
href="/settings"
onClick={onLinkClick}
aria-label="Settings"
title="Settings"
className="flex h-9 items-center gap-3 rounded-lg px-2.5 text-sm text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
>
<SettingsIcon className="h-[17px] w-[17px]" />
Settings
</Link>
<button
type="button"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="flex h-9 w-full items-center gap-3 rounded-lg px-2.5 text-sm text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
>
{theme === "dark" ? (
<>
<Sun className="h-[17px] w-[17px]" />
Light mode
</>
) : (
<>
<Moon className="h-[17px] w-[17px]" />
Dark mode
</>
)}
</button>
<Separator className="my-1" />
<div className="flex items-center gap-3 px-2.5 py-2">
<Avatar className="h-8 w-8 shrink-0">
<AvatarFallback className="text-xs font-medium bg-muted text-foreground">
{user?.name?.charAt(0)?.toUpperCase() ||
user?.email?.charAt(0)?.toUpperCase() ||
"U"}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{user?.name || "User"}
</p>
<p className="truncate text-[11px] text-muted-foreground">
{user?.email}
</p>
</div>
<button
type="button"
onClick={logout}
aria-label="Logout"
title="Logout"
className="grid size-7 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
>
<LogOut className="h-[15px] w-[15px]" />
</button>
</div>
</div>
</div>
);
}
/* ============================================================================
* Sidebar — desktop + mobile (Sheet) wrappers
* ========================================================================== */
export function Sidebar() {
const [open, setOpen] = useState(false);
return (
<>
<aside className="hidden lg:flex lg:w-60 lg:flex-col lg:h-dvh lg:sticky lg:top-0 lg:border-r lg:border-sidebar-border">
<NavContent />
</aside>
<div className="fixed inset-x-0 top-0 z-40 flex h-14 items-center gap-3 border-b bg-background/95 px-4 backdrop-blur-sm lg:hidden">
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger
render={<Button variant="ghost" size="icon" className="h-9 w-9" />}
>
<Menu className="h-5 w-5" />
</SheetTrigger>
<SheetContent
side="left"
className="w-64 gap-0 border-r border-sidebar-border p-0"
>
<SheetTitle className="sr-only">Navigation</SheetTitle>
<NavContent onLinkClick={() => setOpen(false)} />
</SheetContent>
</Sheet>
<div className="text-base font-semibold font-heading-theme">WFDASH</div>
</div>
</>
);
}