"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, BarChart3, BookOpen, Bot, Boxes, Building2, CheckCircle, ChevronDown, ClipboardList, FileText, LayoutDashboard, LogOut, MapPin, Menu, Moon, Package, Plus, QrCode, 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 }, // Office pin tool + (future) visit log. The page itself is a // launcher right now — the full visit-history view is blocked on // PB schema + FleetAI credentials per the rep-visit-check-in // design doc. { href: "/visits", label: "Visits", icon: MapPin }, { href: "/suppliers", label: "Suppliers", icon: Truck }, ]; const quoteItems: NavEntry[] = [ { href: "/quotes", label: "Quote List", icon: FileText }, // ── TEMPORARILY HIDDEN ────────────────────────────────────────────── // Quote sprint board — disabled pending a rebuild. Operators should // process quotes through /quotes/[id]/process until this is // re-enabled. Revert by uncommenting the line below. // { 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 }, // ── TEMPORARILY HIDDEN ────────────────────────────────────────────── // Order sprint board — disabled pending a rebuild. Operators should // work orders through /orders/[id] until this is re-enabled. // Revert by uncommenting the line below. // { 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 }, // Counter-side scanner for the printed delivery-note QR. Separate // standalone PWA at /scan (it declares its own manifest), linked // here so the office can find it from the CRM too. { href: "/scan", label: "DN Scanner", icon: QrCode }, ]; // 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: "/giellie", label: "Giellie Agent", icon: Bot }, // Standard Operating Procedures for the operator roles. Markdown // source of truth lives in docs/sops/ — the /sops route renders // it. See src/lib/sops.ts. { href: "/sops", label: "SOPs", icon: BookOpen }, // Monthly per-rep quotes/orders roll-up. Reuses the same // getBossDashboardData aggregation as the /m phone dashboard's // month tab — see /api/v1/reports/rep-performance. { href: "/reports/rep-performance", label: "Rep Performance", icon: BarChart3 }, ]; /* ============================================================================ * 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 (
{open && (
{items.map((item) => ( ))}
)}
); } /* ============================================================================ * NavLink — single nav row, theme-driven active state * ========================================================================== */ function NavLink({ item, active, onClick, }: { item: NavEntry; active: boolean; onClick?: () => void; }) { return ( {item.label} {active && ( )} ); } /* ============================================================================ * NavContent — the actual sidebar body * ========================================================================== */ function NavContent({ onLinkClick }: { onLinkClick?: () => void }) { const pathname = usePathname(); const { user, logout } = useAuth(); const { theme, setTheme } = useTheme(); return (
{/* 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. */} {/* eslint-disable-next-line @next/next/no-img-element */} WFDASH
WFDASH
Westcott Fasteners
{/* 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. */} {/* Nav */} {/* Bottom block */}
Settings
{user?.name?.charAt(0)?.toUpperCase() || user?.email?.charAt(0)?.toUpperCase() || "U"}

{user?.name || "User"}

{user?.email}

{/* 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. */}
); } /* ============================================================================ * Mobile top-bar label — derived from the current pathname so the bar * reads as "Hamburger · Orders" rather than the redundant "Hamburger · * WFDASH · WFDASH" it shipped with (the brand block is already in the * drawer). Map is checked longest-prefix-first so /orders/sprint wins * over /orders before the generic /orders entry. * ========================================================================== */ const MOBILE_TITLE_MAP: { prefix: string; label: string }[] = [ { prefix: "/collection-confirm", label: "Collection Confirm" }, { prefix: "/collection-map", label: "Collection Map" }, { prefix: "/collection-pool", label: "Collection Pool" }, { prefix: "/delivery-notes", label: "Delivery Notes" }, { prefix: "/stock-orders", label: "Stock Orders" }, { prefix: "/orders/sprint", label: "Sprint" }, { prefix: "/orders/assign", label: "Assign Driver" }, { prefix: "/orders/new", label: "New Order" }, { prefix: "/orders", label: "Orders" }, { prefix: "/quotes/approvals", label: "Quote Approvals" }, { prefix: "/quotes/archive", label: "Quote Archive" }, { prefix: "/quotes", label: "Quotes" }, { prefix: "/customers", label: "Customers" }, { prefix: "/visits", label: "Visits" }, { prefix: "/supplier-map", label: "Supplier Map" }, { prefix: "/suppliers", label: "Suppliers" }, { prefix: "/companies", label: "Companies" }, { prefix: "/pricelist", label: "Pricelist" }, { prefix: "/repairs", label: "Repairs" }, { prefix: "/invoicing", label: "Invoicing" }, { prefix: "/deliveries", label: "Deliveries" }, { prefix: "/sprint", label: "Sprint" }, { prefix: "/stock", label: "Stock" }, { prefix: "/settings", label: "Settings" }, { prefix: "/giellie", label: "Giellie Agent" }, { prefix: "/webview", label: "Webview" }, { prefix: "/dashboard", label: "Dashboard" }, ]; function mobileTitleFor(pathname: string): string { for (const { prefix, label } of MOBILE_TITLE_MAP) { if (pathname === prefix || pathname.startsWith(prefix + "/")) return label; } return "WFDASH"; } /* ============================================================================ * Sidebar — desktop + mobile (Sheet) wrappers * ========================================================================== */ export function Sidebar() { const [open, setOpen] = useState(false); const pathname = usePathname(); const mobileTitle = mobileTitleFor(pathname); return ( <> {/* * Mobile top bar — hamburger on the left, page title centred-left * in the bar. NO brand block here: the brand already lives in the * drawer (NavContent at the top of NavContent renders the logo * + "WFDASH / Westcott Fasteners" stack). Shipping the brand * twice on a 390px viewport ate ~30px of vertical room and * pushed the first row of every shared page below the fold — * see docs/audit/frontend-ux.md §76. */}
} aria-label="Open navigation" > Navigation setOpen(false)} /> {/* * Page title — presentational text, not an

. Every shared * page renders its own

in the page body (e.g. * `app/(app)/orders/page.tsx` "Orders"), and stacking two h1's * on the same page breaks the document outline. Using a span * with `aria-label` keeps it announced as a landmark for * screen readers without duplicating the heading semantics. */} {mobileTitle}

); }