WFDASH-WEBAPP/src/components/sidebar.tsx
SJCoetz 49367558f2 ui(sidebar): rename Hermes nav entry to Giellie
The webview shortcut in the "Other" sidebar group now displays as
"Giellie" instead of "Hermes". Only the visible label changes — the
underlying /webview?src=hermes URL parameter and the ALLOWED-map
key in src/app/(app)/webview/page.tsx are untouched, since those
are internal identifiers (the URL key is what looks up the embeddable
iframe in the ALLOWED whitelist, not a user-facing string).

If you want the internal key renamed too, that's a follow-up that
needs to update both the sidebar link and the ALLOWED map in the
webview route in the same commit.

Co-authored-by: Orca <help@stably.ai>
2026-08-04 20:29:34 +02:00

468 lines
16 KiB
TypeScript

"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 {
Archive,
Boxes,
Building2,
CheckCircle,
ChevronDown,
ClipboardList,
FileText,
Globe,
LayoutDashboard,
LogOut,
MapPin,
Menu,
Moon,
Package,
Plus,
Settings as SettingsIcon,
ShoppingCart,
Sun,
Tag,
Truck,
Users,
Wrench,
Zap,
} from "lucide-react";;
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { BrandStripe } from "@/components/brand-stripe";
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
*
* Flat sections — every nav group renders the same. The active state is
* the theme's single accent. No per-category brand colours 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 },
];
const quoteItems: NavEntry[] = [
{ href: "/quotes", label: "Quote List", icon: FileText },
{ href: "/sprint", label: "Sprint", icon: Zap },
{ href: "/quotes/approvals", label: "Pending Approvals", icon: CheckCircle },
];
// Assign Drivers used to live here as its own sidebar entry, but the
// driver-assignment workflow is now fully embedded in the Collection
// Pool page (per-row Assign + per-bucket bulk Assign). Keeping a
// redundant top-level link only invited confusion, so it's gone.
// The full /orders/assign route is still reachable by URL for the
// older admin flow but is no longer surfaced in the nav.
const orderItems: NavEntry[] = [
{ href: "/orders/new", label: "New Order", icon: Plus },
{ href: "/orders", label: "Order List", icon: ShoppingCart },
{ href: "/orders/sprint", label: "Order Sprint", icon: Truck },
{ href: "/orders/tasks", label: "Buyer Tasks", icon: ClipboardList },
];
// Office-side view of the driver / collection workflow. The driver app
// does the actual pickups — these pages are how the office sees and
// manages that activity. Split out of Operations so the menu reflects
// the actual lifecycle: orders originate in Orders, become physical
// work here in Logistics, then close out in Operations (invoicing).
const logisticsItems: NavEntry[] = [
{ href: "/collection-pool", label: "Collection Pool", icon: Package },
{ href: "/collection-map", label: "Collection Map", icon: MapPin },
{ href: "/collection-confirm", label: "Collection Confirm", icon: CheckCircle },
];
const operationsItems: NavEntry[] = [
{ href: "/invoicing", label: "Invoicing", icon: FileText },
{ href: "/delivery-notes", label: "Delivery Notes", icon: ClipboardList },
// Post-invoice physical-delivery queue. Tracks the
// invoiced → with_driver → checked_in lifecycle. Picked
// up from the Invoicing page once the office hits
// "Mark invoiced", lives here until the driver brings
// back the signed paper and the operator types the
// reference number. n8n will eventually auto-complete
// these (docs/deliveries-n8n-workflow.md).
{ href: "/deliveries", label: "Deliveries", icon: Truck },
];
// Stock is its own top-level group (not nested under Operations) so the
// stock-orders / pricelist / stock-catalog trio lives together. Pricelist
// was previously the lone "Other" item; promoting it here keeps related
// inventory-adjacent surfaces together without inventing a new
// catch-all bucket just for one entry.
const stockNavItems: NavEntry[] = [
{ href: "/stock", label: "Stock", icon: Boxes },
{ href: "/stock-orders", label: "Stock Orders", icon: Archive },
{ href: "/pricelist", label: "Pricelist", icon: Tag },
];
// Repairs lives here for now — it doesn't fit Logistics (it's not a
// driver / collection workflow) and it doesn't fit Operations (which
// is reserved for the order-close-out loop). Move it once it has a
// clearer home.
const otherItems: NavEntry[] = [
{ href: "/repairs", label: "Repairs", icon: Wrench },
{ href: "/webview?src=hermes", label: "Giellie", icon: Globe },
];
/* ============================================================================
* 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 flex h-2 items-center gap-[2px] rounded-full p-[2px]"
aria-hidden
style={{
background:
"linear-gradient(90deg, var(--brand-blue, var(--primary)) 0% 33%, var(--brand-orange, var(--warn)) 33% 66%, var(--brand-red, var(--destructive)) 66% 100%)",
}}
>
<span className="h-1 w-1 rounded-full" style={{ background: "rgba(255,255,255,0.6)" }} />
</span>
)}
</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 — the WFDASH logo (transparent background) sits
naturally on the sidebar surface in both light and dark
modes. Sized 36px so it matches the visual weight of the
previous Package icon. The "Westcott Fasteners" subtitle
keeps the operator-facing context that the original block
carried. */}
<Link
href="/dashboard"
onClick={onLinkClick}
className="flex items-center gap-3 px-5 pt-6 pb-5"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/wfdash-logo-mark.png"
alt="WFDASH"
width={36}
height={36}
className="h-9 w-9 shrink-0"
/>
<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" />
{/* Tri-color brand accent — sits flush against the brand block so
the logo + WFDASH text + the three-peak stripe read as one
signature unit. 3px tall so it reads as a "rule", not a
hairline. */}
<BrandStripe className="h-[3px] shrink-0" />
{/* 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="Logistics"
items={logisticsItems}
pathname={pathname}
onLinkClick={onLinkClick}
/>
<NavSection
label="Operations"
items={operationsItems}
pathname={pathname}
onLinkClick={onLinkClick}
/>
<NavSection
label="Stock"
items={stockNavItems}
pathname={pathname}
onLinkClick={onLinkClick}
/>
{otherItems.length > 0 && (
<NavSection
label="Other"
items={otherItems}
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>
{/* Tri-color brand signature at the bottom of the sidebar —
mirrors the top stripe so the brand identity wraps the
whole sidebar. 2px tall so it reads as a footer rule. */}
<BrandStripe className="h-[2px] shrink-0" />
</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="flex items-center gap-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/wfdash-logo-mark.png"
alt="WFDASH"
width={28}
height={28}
className="h-7 w-7 shrink-0"
/>
<span className="text-base font-semibold font-heading-theme">WFDASH</span>
</div>
</div>
</>
);
}