From 4ded68cec2a27eb1cd1a721e1f1470480601e69f Mon Sep 17 00:00:00 2001 From: Tyler Date: Wed, 17 Jun 2026 00:13:28 -0600 Subject: [PATCH] feat(admin): grouped sidebar IA --- src/components/admin/AdminSidebar.tsx | 661 +++++++++++++------------- src/components/admin/SideNavGroup.tsx | 49 ++ 2 files changed, 380 insertions(+), 330 deletions(-) create mode 100644 src/components/admin/SideNavGroup.tsx diff --git a/src/components/admin/AdminSidebar.tsx b/src/components/admin/AdminSidebar.tsx index ce553b5..96e1fa9 100644 --- a/src/components/admin/AdminSidebar.tsx +++ b/src/components/admin/AdminSidebar.tsx @@ -1,226 +1,146 @@ "use client"; -import { useState, useEffect, useRef, useCallback, KeyboardEvent } from "react"; +import { useState, useEffect, useRef, useCallback, KeyboardEvent, ComponentType } from "react"; import Link from "next/link"; -import { usePathname } from "next/navigation"; -import { useRouter } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; +import { + LayoutDashboard, + ShoppingCart, + MapPin, + Package, + Truck, + Ship, + Mail, + Store, + Sparkles, + Upload, + BrainCircuit, + Clock, + Droplets, + Route, + ChartBar, + Receipt, + Settings, + LogOut, + Menu, + X, + ArrowLeft, +} from "lucide-react"; import { signOutAction } from "@/actions/auth-actions"; import BrandSelector from "@/components/admin/BrandSelector"; +import SideNavGroup from "@/components/admin/SideNavGroup"; -// Elegant warm sidebar design -// Colors: parchment 100 bg, soft linen text, powder petal accent +// Sidebar tokens used (from src/styles/admin-design-system.css): +// --admin-sidebar-bg (#2A2520) +// --admin-sidebar-text (#B8B4A8) +// --admin-sidebar-hover (#3A352F) +// --admin-sidebar-active (#4A443C) +// --admin-sidebar-accent (#D4A24C — amber, used for active left-border + dot) +// The "amber accent" replaces the old `--admin-accent` (citrus orange) in the +// sidebar only — primary buttons elsewhere still use the deep botanical green. -type NavItem = { - href?: string; +type IconComponent = ComponentType<{ className?: string; size?: number | string }>; + +type NavItemDef = { + href: string; label: string; - icon?: string; - divider?: boolean; + icon: IconComponent; + /** If set, this item is hidden when `enabledAddons[key] === false`. */ + addonKey?: string; + /** When true, only `userRole === "platform_admin"` sees this item. */ + requiresPlatformAdmin?: boolean; }; -const NAV_ITEMS: NavItem[] = [ - // Main - { href: "/admin", label: "Dashboard", icon: "grid" }, - { href: "/admin/orders", label: "Orders", icon: "shopping-cart" }, - { href: "/admin/stops", label: "Stops & Routes", icon: "map-pin" }, - { href: "/admin/products", label: "Products", icon: "package" }, - { href: "/admin/communications", label: "Communications", icon: "mail" }, - // Settings section - { divider: true, label: "Settings" }, - { href: "/admin/settings", label: "General", icon: "settings" }, - { href: "/admin/time-tracking", label: "Workers & PINs", icon: "users" }, - { href: "/admin/time-tracking?tab=tasks", label: "Tasks", icon: "clipboard" }, - { href: "/admin/users", label: "Users & Permissions", icon: "shield" }, - { href: "/admin/settings/integrations", label: "Integrations", icon: "plug" }, - { href: "/admin/settings/billing", label: "Billing", icon: "billing" }, +type NavGroup = { + label: string; + items: NavItemDef[]; +}; + +/** + * Grouped nav IA per `docs/superpowers/specs/2026-06-17-admin-redesign.md` §4. + * Order is intentional (top-to-bottom = frequency-of-use + mental flow). + * + * - Workspace : Dashboard is always shown; Command Center is platform_admin-only. + * - Operations : Day-to-day order/stop/product flow. + * - Communications: Harvest Reach only — the other sub-items live inside that page. + * - Growth : Wholesale portal, data imports, AI assistant. + * - Tracking : Time tracking is always on; Water Log / Route Trace are + * gated on their respective add-ons via `enabledAddons`. + * - Insights : Reporting + tax. + * - Settings : Single entry; sub-pages are tabs inside /admin/settings. + */ +const NAV_GROUPS: NavGroup[] = [ + { + label: "Workspace", + items: [ + { href: "/admin", label: "Dashboard", icon: LayoutDashboard }, + { + href: "/admin/command-center", + label: "Command Center", + icon: Sparkles, + requiresPlatformAdmin: true, + }, + ], + }, + { + label: "Operations", + items: [ + { href: "/admin/orders", label: "Orders", icon: ShoppingCart }, + { href: "/admin/stops", label: "Stops & Routes", icon: MapPin }, + { href: "/admin/products", label: "Products", icon: Package }, + { href: "/admin/pickup", label: "Driver Pickup", icon: Truck }, + { href: "/admin/shipping", label: "Shipping", icon: Ship }, + ], + }, + { + label: "Communications", + items: [ + { href: "/admin/communications", label: "Harvest Reach", icon: Mail }, + ], + }, + { + label: "Growth", + items: [ + { href: "/admin/wholesale", label: "Wholesale", icon: Store }, + { href: "/admin/import", label: "Import Center", icon: Upload }, + { href: "/admin/settings/ai", label: "AI Intelligence", icon: BrainCircuit }, + ], + }, + { + label: "Tracking", + items: [ + { href: "/admin/time-tracking", label: "Time Tracking", icon: Clock }, + { href: "/admin/water-log", label: "Water Log", icon: Droplets, addonKey: "water_log" }, + { href: "/admin/route-trace", label: "Route Trace", icon: Route, addonKey: "route_trace" }, + ], + }, + { + label: "Insights", + items: [ + { href: "/admin/reports", label: "Reports", icon: ChartBar }, + { href: "/admin/taxes", label: "Tax Dashboard", icon: Receipt }, + ], + }, + { + label: "Settings", + items: [ + { href: "/admin/settings", label: "Settings", icon: Settings }, + ], + }, ]; -// Icon components -function GridIcon({ className }: { className?: string }) { - return ( - - ); -} - -function CartIcon({ className }: { className?: string }) { - return ( - - ); -} - -function MapPinIcon({ className }: { className?: string }) { - return ( - - ); -} - -function PackageIcon({ className }: { className?: string }) { - return ( - - ); -} - -function ClipboardIcon({ className }: { className?: string }) { - return ( - - ); -} - -function ClockIcon({ className }: { className?: string }) { - return ( - - ); -} - -function MailIcon({ className }: { className?: string }) { - return ( - - ); -} - -function SparkleIcon({ className }: { className?: string }) { - return ( - - ); -} - -function SettingsCogIcon({ open }: { open: boolean }) { - return ( - - ); -} - -function ChevronIcon({ open }: { open: boolean }) { - return ( - - ); -} - -function HamburgerIcon() { - return ( - - ); -} - -function CloseIcon() { - return ( - - ); -} - -function BillingIcon({ className }: { className?: string }) { - return ( - - ); -} - -function PuzzleIcon({ className }: { className?: string }) { - return ( - - ); -} - -function PlugIcon({ className }: { className?: string }) { - return ( - - ); -} - -function TruckIcon({ className }: { className?: string }) { - return ( - - ); -} - -function SquareIcon({ className }: { className?: string }) { - return ( - - ); -} - -function LogoutIcon({ className }: { className?: string }) { - return ( - - ); -} - -function UsersIcon({ className }: { className?: string }) { - return ( - - ); -} - -function ShieldIcon({ className }: { className?: string }) { - return ( - - ); -} - -const ICON_MAP: Record = { - grid: , - "shopping-cart": , - "map-pin": , - package: , - clipboard: , - clock: , - mail: , - settings: , - advanced: , - billing: , - puzzle: , - plug: , - sparkles: , - truck: , - square: , - users: , - shield: , -}; - type SidebarProps = { userRole?: string | null; brandIds?: string[]; activeBrandId?: string | null; brands?: { id: string; name: string; slug: string; logo_url: string | null }[]; + /** + * Map of add-on key → enabled. When a key is `false` (or omitted, with the + * add-on not in the map), any nav item with that `addonKey` is hidden. + * When the prop itself is omitted, all add-on-gated items are shown + * (back-compat with the current admin layout, which doesn't pass it). + */ + enabledAddons?: Record; }; export default function AdminSidebar({ @@ -228,6 +148,7 @@ export default function AdminSidebar({ brandIds, activeBrandId, brands, + enabledAddons, }: SidebarProps) { const pathname = usePathname(); const router = useRouter(); @@ -237,15 +158,52 @@ export default function AdminSidebar({ const mobileMenuRef = useRef(null); const closeButtonRef = useRef(null); - const roleLabel = userRole === "platform_admin" ? "Platform Admin" - : userRole === "brand_admin" ? "Brand Admin" - : userRole === "store_employee" ? "Store Employee" - : null; + const roleLabel = + userRole === "platform_admin" + ? "Platform Admin" + : userRole === "brand_admin" + ? "Brand Admin" + : userRole === "store_employee" + ? "Store Employee" + : null; - const isActive = useCallback((href: string) => { - if (href === "/admin") return pathname === "/admin"; - return pathname.startsWith(href); - }, [pathname]); + /** + * Build the filtered list of visible nav items for the current viewer. + * Honors role-gating (Command Center) and add-on gating (Water Log, Route Trace). + * If `enabledAddons` is undefined, add-on-gated items are shown (no gating). + */ + const visibleItems: NavItemDef[] = NAV_GROUPS.flatMap((group) => { + return group.items.filter((item) => { + if (item.requiresPlatformAdmin && userRole !== "platform_admin") { + return false; + } + if (item.addonKey && enabledAddons && enabledAddons[item.addonKey] === false) { + return false; + } + return true; + }); + }); + + /** + * Groups rendered, with empty ones removed. The Communications group is + * always rendered today (Harvest Reach is always visible) — but if a + * future iteration hides Harvest Reach for some role, the entire group + * header goes away rather than rendering an empty section. + */ + const visibleGroups: NavGroup[] = NAV_GROUPS + .map((group) => ({ + ...group, + items: group.items.filter((item) => visibleItems.includes(item)), + })) + .filter((group) => group.items.length > 0); + + const isActive = useCallback( + (href: string) => { + if (href === "/admin") return pathname === "/admin"; + return pathname.startsWith(href); + }, + [pathname], + ); // Close mobile menu with animation const closeMobileMenu = useCallback(() => { @@ -283,31 +241,106 @@ export default function AdminSidebar({ }; }, [mobileOpen]); - // Keyboard navigation for nav items - const handleNavKeyDown = useCallback((e: KeyboardEvent, href: string, index: number) => { - const navLinks = NAV_ITEMS.filter(item => !item.divider); - - if (e.key === "ArrowDown") { - e.preventDefault(); - const nextIndex = (index + 1) % navLinks.length; - const nextLink = document.querySelector(`[data-nav-index="${nextIndex}"]`) as HTMLAnchorElement; - nextLink?.focus(); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - const prevIndex = index === 0 ? navLinks.length - 1 : index - 1; - const prevLink = document.querySelector(`[data-nav-index="${prevIndex}"]`) as HTMLAnchorElement; - prevLink?.focus(); - } else if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - router.push(href); - if (mobileOpen) closeMobileMenu(); - } - }, [router, mobileOpen, closeMobileMenu]); + // Keyboard navigation for nav items (arrow up/down loops, enter/space activates) + const handleNavKeyDown = useCallback( + (e: KeyboardEvent, href: string, index: number) => { + const total = visibleItems.length; + if (total === 0) return; + + if (e.key === "ArrowDown") { + e.preventDefault(); + const nextIndex = (index + 1) % total; + const nextLink = document.querySelector( + `[data-nav-index="${nextIndex}"]`, + ) as HTMLAnchorElement | null; + nextLink?.focus(); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + const prevIndex = index === 0 ? total - 1 : index - 1; + const prevLink = document.querySelector( + `[data-nav-index="${prevIndex}"]`, + ) as HTMLAnchorElement | null; + prevLink?.focus(); + } else if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + router.push(href); + if (mobileOpen) closeMobileMenu(); + } + }, + [router, mobileOpen, closeMobileMenu, visibleItems.length], + ); async function handleLogout() { await signOutAction(); } + // Shared nav-link renderer. Used by both the desktop sidebar and the mobile + // slide-in panel — they're the same list, just in a different container. + const renderNavLink = (item: NavItemDef, index: number) => { + const active = isActive(item.href); + const Icon = item.icon; + return ( +
  • + closeMobileMenu()} + onKeyDown={(e) => handleNavKeyDown(e, item.href, index)} + className={[ + "group flex items-center gap-2.5 pl-3 pr-2.5 py-2 rounded-r-md text-xs font-medium", + "border-l-[3px] transition-all duration-200", + "focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--admin-sidebar-bg)]", + ].join(" ")} + style={ + active + ? { + backgroundColor: "var(--admin-sidebar-active)", + color: "#F4F1E8", + borderLeftColor: "var(--admin-sidebar-accent)", + } + : { + color: "var(--admin-sidebar-text)", + borderLeftColor: "transparent", + } + } + onMouseEnter={(e) => { + if (!active) { + e.currentTarget.style.backgroundColor = + "var(--admin-sidebar-hover)"; + } + }} + onMouseLeave={(e) => { + if (!active) { + e.currentTarget.style.backgroundColor = "transparent"; + } + }} + aria-current={active ? "page" : undefined} + tabIndex={0} + > + + {item.label} + {active && ( +
  • + ); + }; + return ( <> {/* Mobile hamburger button - accessible */} @@ -319,7 +352,7 @@ export default function AdminSidebar({ aria-controls="admin-sidebar" type="button" > - +