WFDASH-WEBAPP/src/components/sidebar.tsx

465 lines
15 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,
ChevronDown,
MapPinned,
Settings as SettingsIcon,
} 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, type ReactNode } from "react";
/* ============================================================================
* CATEGORY SYSTEM
*
* Sidebar nav is grouped into 4 categories, each with a fixed brand color
* so users can scan by color. Colors are intentionally hard-coded — they
* stay the same regardless of which theme is active, so the sidebar reads
* consistently.
*
* dashboard → blue (brand)
* management → blue (brand)
* quotes → gold (brand)
* operations → red (brand)
* ========================================================================== */
type CategoryId = "dashboard" | "management" | "quotes" | "operations";
const CATEGORY_COLORS: Record<CategoryId, string> = {
dashboard: "#4C96D7",
management: "#4C96D7",
quotes: "#F2AF58",
operations: "#E94E49",
};
// Helper — produce 8-digit hex with alpha (browser-portable, no color-mix needed)
function withAlpha(hex: string, alphaPercent: number): string {
const a = Math.round((alphaPercent / 100) * 255)
.toString(16)
.padStart(2, "0");
return `${hex}${a}`;
}
type NavEntry = {
href: string;
label: string;
icon: LucideIcon;
category: CategoryId;
};
const dashboardItems: NavEntry[] = [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard, category: "dashboard" },
];
const managementItems: NavEntry[] = [
{ href: "/customers", label: "Customers", icon: Users, category: "management" },
{ href: "/companies", label: "Companies", icon: Building2, category: "management" },
{ href: "/suppliers", label: "Suppliers", icon: Truck, category: "management" },
{ href: "/supplier-map", label: "Supplier Map", icon: MapPinned, category: "management" },
];
const quoteItems: NavEntry[] = [
{ href: "/quotes", label: "All Quotes", icon: FileText, category: "quotes" },
{ href: "/quotes/approvals", label: "Pending Approvals", icon: CheckCircle, category: "quotes" },
{ href: "/quotes/archived", label: "Archived", icon: Archive, category: "quotes" },
];
const operationsItems: NavEntry[] = [
{ href: "/orders", label: "Orders", icon: ShoppingCart, category: "operations" },
{ href: "/collection-pool", label: "Collection Pool", icon: Package, category: "operations" },
{ href: "/delivery-notes", label: "Delivery Notes", icon: ClipboardList, category: "operations" },
{ href: "/repairs", label: "Repairs", icon: Wrench, category: "operations" },
];
const CATEGORY_LABELS: Record<CategoryId, string> = {
dashboard: "Dashboard",
management: "Management",
quotes: "Quotes",
operations: "Operations",
};
/* ============================================================================
* NavItem — single nav link with category-colored icon
* ========================================================================== */
function NavItem({
item,
active,
onClick,
indent = false,
}: {
item: NavEntry;
active: boolean;
onClick?: () => void;
indent?: boolean;
}) {
const color = CATEGORY_COLORS[item.category];
// Inactive: subtle category tint. Active: white-on-primary from theme.
const inactiveStyle = active
? undefined
: { color: withAlpha(color, 78) };
return (
<Link
href={item.href}
onClick={onClick}
style={inactiveStyle}
className={`group flex items-center gap-3 rounded-xl ${
indent ? "px-2.5 py-2 text-sm" : "px-3 py-2.5 text-sm"
} font-medium transition-all duration-150 ${
active
? "bg-primary text-primary-foreground shadow-sm"
: "hover:opacity-80"
}`}
>
<item.icon
className={`${indent ? "h-4 w-4" : "h-[18px] w-[18px]"} shrink-0`}
style={active ? { color: "var(--primary-foreground)" } : { color }}
/>
{item.label}
</Link>
);
}
/* ============================================================================
* NavGroup — accordion wrapper tinted with category color
* ========================================================================== */
function NavGroup({
category,
open,
onToggle,
headerActive,
headerIcon,
items,
pathname,
onLinkClick,
}: {
category: CategoryId;
open: boolean;
onToggle: () => void;
headerActive: boolean;
headerIcon: LucideIcon;
items: NavEntry[];
pathname: string;
onLinkClick?: () => void;
}) {
const color = CATEGORY_COLORS[category];
const HeaderIcon = headerIcon;
return (
<div
className="rounded-xl border p-1.5"
style={{
background: withAlpha(color, open ? 10 : 6),
borderColor: withAlpha(color, open ? 28 : 18),
}}
>
<button
type="button"
onClick={onToggle}
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-xs font-semibold uppercase tracking-wide transition-colors ${
headerActive ? "" : "hover:opacity-80"
}`}
style={{
color: headerActive ? color : withAlpha(color, 78),
}}
>
<HeaderIcon
className="h-3.5 w-3.5"
style={{ color: headerActive ? color : color }}
/>
{CATEGORY_LABELS[category]}
<ChevronDown
className={`ml-auto h-3.5 w-3.5 transition-transform ${open ? "rotate-180" : ""}`}
style={{ color: withAlpha(color, 60) }}
/>
</button>
{open && (
<div className="mt-1 space-y-0.5">
{items.map((item) => {
const isActive =
item.href === "/quotes"
? pathname === "/quotes"
: pathname === item.href || pathname.startsWith(item.href + "/");
return (
<NavItem
key={item.href}
item={item}
active={isActive}
onClick={onLinkClick}
indent
/>
);
})}
</div>
)}
</div>
);
}
/* ============================================================================
* NavContent — the actual sidebar body
* ========================================================================== */
function NavContent({ onLinkClick }: { onLinkClick?: () => void }) {
const pathname = usePathname();
const { user, logout } = useAuth();
const { theme, setTheme } = useTheme();
const isManagementActive = managementItems.some(
(item) => pathname === item.href || pathname.startsWith(item.href + "/")
);
const isQuotesActive =
pathname === "/quotes" || pathname.startsWith("/quotes/");
const isOperationsActive = operationsItems.some(
(item) => pathname === item.href || pathname.startsWith(item.href + "/")
);
const [managementOpen, setManagementOpen] = useState(isManagementActive);
const [quotesOpen, setQuotesOpen] = useState(isQuotesActive);
// Auto-open accordion when route inside it becomes active
useEffect(() => {
if (isManagementActive) setManagementOpen(true);
if (isQuotesActive) setQuotesOpen(true);
}, [isManagementActive, isQuotesActive]);
const isDashboardActive =
pathname === "/dashboard" || pathname.startsWith("/dashboard/");
return (
<div className="flex flex-col h-full">
{/* Brand block */}
<div className="px-5 pt-6 pb-4">
<div className="flex items-center gap-3">
<div className="grid size-10 place-items-center rounded-xl bg-primary text-primary-foreground shadow-sm">
<Package className="h-5 w-5" />
</div>
<div>
<h1 className="text-lg font-semibold tracking-tight text-foreground font-heading-theme">
WFDASH
</h1>
<p className="text-xs text-muted-foreground mt-0.5">
Westcott Fasteners
</p>
</div>
</div>
{/* Brand color stripe */}
<div className="mt-4 grid grid-cols-3 gap-1">
<span className="h-1.5 rounded-full" style={{ background: "#4C96D7" }} />
<span className="h-1.5 rounded-full" style={{ background: "#F2AF58" }} />
<span className="h-1.5 rounded-full" style={{ background: "#E94E49" }} />
</div>
</div>
<Separator className="mx-3" />
{/* Main nav */}
<ScrollArea className="flex-1 px-3 py-3">
<nav className="space-y-1.5">
{/* Dashboard — flat top item, no accordion */}
<NavItem
item={dashboardItems[0]}
active={isDashboardActive}
onClick={onLinkClick}
/>
{/* Management — accordion */}
<NavGroup
category="management"
open={managementOpen}
onToggle={() => setManagementOpen((o) => !o)}
headerActive={isManagementActive}
headerIcon={Users}
items={managementItems}
pathname={pathname}
onLinkClick={onLinkClick}
/>
{/* Quotes — accordion */}
<NavGroup
category="quotes"
open={quotesOpen}
onToggle={() => setQuotesOpen((o) => !o)}
headerActive={isQuotesActive}
headerIcon={FileText}
items={quoteItems}
pathname={pathname}
onLinkClick={onLinkClick}
/>
{/* Operations — flat items, but with a tiny colored gutter on the left */}
<OperationsList
active={isOperationsActive}
pathname={pathname}
onLinkClick={onLinkClick}
/>
</nav>
</ScrollArea>
<Separator className="mx-3" />
{/* Bottom block: settings (icon-only) + theme + avatar + logout */}
<div className="p-3 space-y-2">
{/* Settings — small icon-only button bottom-left */}
<Link
href="/settings"
onClick={onLinkClick}
aria-label="Settings"
title="Settings"
className="grid size-9 place-items-center rounded-xl text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<SettingsIcon className="h-[18px] w-[18px]" />
</Link>
{/* Theme toggle */}
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-3 text-muted-foreground hover:text-foreground h-9 px-2.5 rounded-xl"
onClick={() => setTheme(theme === "dark" ? "warm" : "dark")}
>
{theme === "dark" ? (
<>
<Sun className="h-[18px] w-[18px]" />
<span className="text-sm">Light Mode</span>
</>
) : (
<>
<Moon className="h-[18px] w-[18px]" />
<span className="text-sm">Dark Mode</span>
</>
)}
</Button>
{/* User */}
<div className="flex items-center gap-3 px-2.5 py-2">
<Avatar className="h-9 w-9 shrink-0 ring-2 ring-border/50">
<AvatarFallback className="text-xs font-medium bg-muted">
{user?.name?.charAt(0)?.toUpperCase() ||
user?.email?.charAt(0)?.toUpperCase() ||
"U"}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{user?.name || "User"}
</p>
<p className="text-xs text-muted-foreground truncate">
{user?.email}
</p>
</div>
</div>
{/* Logout */}
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-3 text-destructive hover:text-destructive hover:bg-destructive/8 h-9 px-2.5 rounded-xl"
onClick={logout}
>
<LogOut className="h-[18px] w-[18px]" />
<span className="text-sm">Logout</span>
</Button>
</div>
</div>
);
}
/* ============================================================================
* OperationsList — flat items with a 4px red left gutter
*
* Operations is the only flat category. To make it visually consistent with
* the accordion groups above, we wrap it in a thin red gutter that hints
* at the category color without painting a full background.
* ========================================================================== */
function OperationsList({
active,
pathname,
onLinkClick,
}: {
active: boolean;
pathname: string;
onLinkClick?: () => void;
}): ReactNode {
const color = CATEGORY_COLORS.operations;
return (
<div
className="rounded-xl border p-1.5 space-y-0.5"
style={{
background: withAlpha(color, active ? 10 : 6),
borderColor: withAlpha(color, active ? 28 : 18),
}}
>
{/* Tiny header label so the category is identifiable */}
<div
className="px-2 py-1 text-xs font-semibold uppercase tracking-wide flex items-center gap-2"
style={{ color: withAlpha(color, 78) }}
>
<ShoppingCart className="h-3.5 w-3.5" style={{ color }} />
Operations
</div>
{operationsItems.map((item) => {
const isActive =
pathname === item.href || pathname.startsWith(item.href + "/");
return (
<NavItem
key={item.href}
item={item}
active={isActive}
onClick={onLinkClick}
indent
/>
);
})}
</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 bg-sidebar border-r border-sidebar-border">
<NavContent />
</aside>
<div className="lg:hidden fixed top-0 left-0 right-0 z-40 flex items-center gap-3 border-b bg-background/95 backdrop-blur-sm px-4 h-14">
<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-60 p-0">
<SheetTitle className="sr-only">Navigation</SheetTitle>
<NavContent onLinkClick={() => setOpen(false)} />
</SheetContent>
</Sheet>
<h1 className="text-lg font-semibold font-heading-theme">WFDASH</h1>
</div>
</>
);
}