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>
468 lines
16 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|