519 lines
17 KiB
TypeScript
519 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useRef, useCallback, KeyboardEvent, ComponentType } from "react";
|
|
import Link from "next/link";
|
|
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";
|
|
|
|
// 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 IconComponent = ComponentType<{ className?: string; size?: number | string }>;
|
|
|
|
type NavItemDef = {
|
|
href: string;
|
|
label: string;
|
|
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;
|
|
};
|
|
|
|
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 },
|
|
],
|
|
},
|
|
];
|
|
|
|
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<string, boolean>;
|
|
};
|
|
|
|
export default function AdminSidebar({
|
|
userRole,
|
|
brandIds,
|
|
activeBrandId,
|
|
brands,
|
|
enabledAddons,
|
|
}: SidebarProps) {
|
|
const pathname = usePathname();
|
|
const router = useRouter();
|
|
const [mobileOpen, setMobileOpen] = useState(false);
|
|
const [isClosing, setIsClosing] = useState(false);
|
|
const sidebarRef = useRef<HTMLDivElement>(null);
|
|
const mobileMenuRef = useRef<HTMLDivElement>(null);
|
|
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
|
|
|
const roleLabel =
|
|
userRole === "platform_admin"
|
|
? "Platform Admin"
|
|
: userRole === "brand_admin"
|
|
? "Brand Admin"
|
|
: userRole === "store_employee"
|
|
? "Store Employee"
|
|
: null;
|
|
|
|
/**
|
|
* 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(() => {
|
|
setIsClosing(true);
|
|
setTimeout(() => {
|
|
setMobileOpen(false);
|
|
setIsClosing(false);
|
|
}, 200);
|
|
}, []);
|
|
|
|
// Handle escape key
|
|
useEffect(() => {
|
|
const handleEscape = (e: globalThis.KeyboardEvent) => {
|
|
if (e.key === "Escape" && mobileOpen) {
|
|
closeMobileMenu();
|
|
}
|
|
};
|
|
document.addEventListener("keydown", handleEscape);
|
|
return () => document.removeEventListener("keydown", handleEscape);
|
|
}, [mobileOpen, closeMobileMenu]);
|
|
|
|
// Focus trap and body scroll lock
|
|
useEffect(() => {
|
|
if (mobileOpen) {
|
|
document.body.style.overflow = "hidden";
|
|
// Focus close button after animation
|
|
setTimeout(() => {
|
|
closeButtonRef.current?.focus();
|
|
}, 100);
|
|
} else {
|
|
document.body.style.overflow = "";
|
|
}
|
|
return () => {
|
|
document.body.style.overflow = "";
|
|
};
|
|
}, [mobileOpen]);
|
|
|
|
// Keyboard navigation for nav items (arrow up/down loops, enter/space activates)
|
|
const handleNavKeyDown = useCallback(
|
|
(e: KeyboardEvent<HTMLAnchorElement>, 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 (
|
|
<li key={item.href}>
|
|
<Link
|
|
href={item.href}
|
|
data-nav-index={index}
|
|
onClick={() => 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}
|
|
>
|
|
<span
|
|
className="flex-shrink-0 transition-colors duration-200"
|
|
style={{
|
|
color: active
|
|
? "var(--admin-sidebar-accent)"
|
|
: "rgba(208, 203, 180, 0.6)",
|
|
}}
|
|
aria-hidden="true"
|
|
>
|
|
<Icon className="w-4 h-4" />
|
|
</span>
|
|
<span className="flex-1 truncate">{item.label}</span>
|
|
{active && (
|
|
<span
|
|
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
|
style={{ backgroundColor: "var(--admin-sidebar-accent)" }}
|
|
aria-hidden="true"
|
|
/>
|
|
)}
|
|
</Link>
|
|
</li>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{/* Mobile hamburger button - accessible */}
|
|
<button
|
|
onClick={() => setMobileOpen(true)}
|
|
className="fixed top-4 left-4 z-50 lg:hidden rounded-xl bg-white shadow-lg border border-[var(--admin-border)] p-3 transition-transform hover:scale-105 active:scale-95"
|
|
aria-label="Open navigation menu"
|
|
aria-expanded={mobileOpen}
|
|
aria-controls="admin-sidebar"
|
|
type="button"
|
|
>
|
|
<Menu className="w-5 h-5" aria-hidden="true" />
|
|
</button>
|
|
|
|
{/* Mobile overlay backdrop with blur */}
|
|
{mobileOpen && (
|
|
<div
|
|
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-40 lg:hidden transition-opacity duration-200"
|
|
style={{ opacity: isClosing ? 0 : 1 }}
|
|
onClick={closeMobileMenu}
|
|
aria-hidden="true"
|
|
/>
|
|
)}
|
|
|
|
{/* Sidebar panel - dark canvas, grouped nav, amber accent for active */}
|
|
<aside
|
|
ref={sidebarRef}
|
|
id="admin-sidebar"
|
|
className={[
|
|
"fixed top-0 left-0 h-full w-60 z-50",
|
|
"border-r flex flex-col",
|
|
"transition-transform duration-300 ease-out lg:translate-x-0",
|
|
mobileOpen ? "translate-x-0" : "-translate-x-full",
|
|
isClosing ? "opacity-90" : "opacity-100",
|
|
].join(" ")}
|
|
style={{
|
|
backgroundColor: "var(--admin-sidebar-bg)",
|
|
borderColor: "rgba(208, 203, 180, 0.2)",
|
|
}}
|
|
role="navigation"
|
|
aria-label="Admin navigation"
|
|
>
|
|
{/* Logo row with close button on mobile */}
|
|
<div
|
|
className="flex items-center h-16 px-5 border-b flex-shrink-0"
|
|
style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}
|
|
>
|
|
<div className="flex items-center justify-between w-full">
|
|
<Link
|
|
href="/admin"
|
|
onClick={() => closeMobileMenu()}
|
|
className="flex items-center gap-3 text-white hover:opacity-90 transition-opacity"
|
|
aria-label="Admin Dashboard home"
|
|
>
|
|
<div
|
|
className="flex h-9 w-9 items-center justify-center rounded-xl shadow-sm transition-transform hover:scale-105"
|
|
style={{ backgroundColor: "var(--admin-sidebar-accent)" }}
|
|
>
|
|
<ArrowLeft className="h-5 w-5 text-white" aria-hidden="true" />
|
|
</div>
|
|
<span className="text-sm font-semibold tracking-tight">Admin</span>
|
|
</Link>
|
|
|
|
{/* Mobile close button */}
|
|
<button
|
|
ref={closeButtonRef}
|
|
onClick={closeMobileMenu}
|
|
className="lg:hidden p-2 rounded-lg text-white/60 hover:text-white hover:bg-white/10 transition-colors"
|
|
aria-label="Close navigation menu"
|
|
type="button"
|
|
>
|
|
<X className="w-5 h-5" aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Back to site link */}
|
|
<div
|
|
className="px-5 py-3 border-b flex-shrink-0"
|
|
style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}
|
|
>
|
|
<Link
|
|
href="/"
|
|
className="text-xs transition-colors flex items-center gap-1.5 hover:text-white"
|
|
style={{ color: "var(--admin-sidebar-text)" }}
|
|
>
|
|
<ArrowLeft className="w-3.5 h-3.5" aria-hidden="true" />
|
|
Back to Site
|
|
</Link>
|
|
</div>
|
|
|
|
{/* Nav groups with keyboard navigation */}
|
|
<nav
|
|
ref={mobileMenuRef}
|
|
className="flex-1 overflow-y-auto overflow-x-hidden pt-3 pb-2 scrollbar-thin"
|
|
>
|
|
{visibleGroups.map((group) => (
|
|
<SideNavGroup key={group.label} label={group.label}>
|
|
{group.items.map((item) => {
|
|
const idx = visibleItems.findIndex((v) => v.href === item.href);
|
|
return renderNavLink(item, idx);
|
|
})}
|
|
</SideNavGroup>
|
|
))}
|
|
</nav>
|
|
|
|
{/* Bottom: command-palette tip + brand picker + role + sign out */}
|
|
<div
|
|
className="px-4 py-4 border-t flex-shrink-0 space-y-3"
|
|
style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}
|
|
>
|
|
{/* Cmd+K hint — the command palette itself is mounted separately
|
|
in the admin layout; this is just a discoverability nudge. */}
|
|
<div
|
|
className="flex items-center justify-between text-[10px] uppercase tracking-widest"
|
|
style={{ color: "rgba(195, 195, 193, 0.5)" }}
|
|
aria-hidden="true"
|
|
>
|
|
<span>Tip</span>
|
|
<kbd
|
|
className="font-mono px-1.5 py-0.5 rounded border"
|
|
style={{
|
|
color: "rgba(195, 195, 193, 0.7)",
|
|
backgroundColor: "rgba(208, 203, 180, 0.1)",
|
|
borderColor: "rgba(208, 203, 180, 0.2)",
|
|
}}
|
|
>
|
|
⌘K
|
|
</kbd>
|
|
</div>
|
|
|
|
{/* Brand selector — only show when admin has access to brands */}
|
|
{brands && brands.length > 0 && (
|
|
<BrandSelector
|
|
brands={brands}
|
|
activeBrandId={activeBrandId ?? null}
|
|
showAllBrandsOption={userRole === "platform_admin"}
|
|
isMultiBrandAdmin={(brandIds?.length ?? 0) > 1}
|
|
/>
|
|
)}
|
|
|
|
{roleLabel && (
|
|
<div
|
|
className="px-3 py-2.5 rounded-xl border"
|
|
style={{
|
|
backgroundColor: "rgba(208, 203, 180, 0.1)",
|
|
borderColor: "rgba(208, 203, 180, 0.2)",
|
|
}}
|
|
>
|
|
<p
|
|
className="text-[10px] font-medium uppercase tracking-widest mb-0.5"
|
|
style={{ color: "rgba(195, 195, 193, 0.6)" }}
|
|
>
|
|
{roleLabel}
|
|
</p>
|
|
<p className="text-xs" style={{ color: "rgba(195, 195, 193, 0.8)" }}>
|
|
Signed in
|
|
</p>
|
|
</div>
|
|
)}
|
|
<button
|
|
onClick={handleLogout}
|
|
className="w-full px-3 py-2.5 rounded-xl text-sm font-medium transition-all flex items-center gap-2 hover:bg-white/10"
|
|
style={{ color: "rgba(195, 195, 193, 0.7)" }}
|
|
aria-label="Sign out of admin"
|
|
type="button"
|
|
>
|
|
<LogOut className="w-4 h-4" aria-hidden="true" />
|
|
Sign out
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
</>
|
|
);
|
|
}
|