MEGAINFRA-WEBAPP/src/components/layout/sidebar.tsx
DEls 30a45f759d Merge branch 'feat/site-scope' into main
Global site scope: one switcher in the topbar, carried in the URL as
?site=, resolved server-side via the getSiteScope() seam, and applied as
a PocketBase site= filter across every dashboard module. Replaces the
four independent per-table project-code dropdowns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	docs/superpowers/plans/2026-09-11-site-scope.md
2026-09-11 23:57:51 +02:00

313 lines
No EOL
11 KiB
TypeScript

"use client";
import * as React from "react";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import {
Building2,
Coins,
Contact,
Fuel,
LayoutDashboard,
LogOut,
Menu,
Package,
Receipt,
ScrollText,
Sparkles,
Truck,
Users,
Wrench,
X,
} from "lucide-react";
import { SiteScopeSwitcher } from "@/components/layout/site-scope-switcher";
import { scopeAwareHref } from "@/components/layout/sidebar-scope-href";
import { Logo } from "@/components/logo";
import { ThemeToggle } from "@/components/theme-toggle";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { useAuth } from "@/hooks/use-auth";
import { cn } from "@/lib/utils";
interface NavItem {
href: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
description?: string;
}
/**
* A grouped navigation entry. Sub-items are always visible (no
* accordion/expand-collapse) — the spec is explicit about that. The
* group's own label is a link to its landing page; the heading is
* active when the pathname matches the landing page exactly, without
* matching any child's prefix.
*/
interface NavGroup {
kind: "group";
href: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
children: NavItem[];
}
type NavEntry = NavItem | NavGroup;
// Navigation entries are added page-by-page as each PocketBase-backed
// route is built. Add the matching page under app/dashboard/<href> when
// wiring up the next module.
const NAV: NavEntry[] = [
{ href: "/dashboard", label: "Overview", icon: LayoutDashboard, description: "Site-at-a-glance KPIs" },
{ href: "/dashboard/sites", label: "Sites", icon: Building2, description: "Contracts, burn, profitability" },
{
kind: "group",
href: "/dashboard/workforce",
label: "Workforce",
icon: Users,
children: [
{ href: "/dashboard/workforce/workers", label: "Workers", icon: Users },
{ href: "/dashboard/workforce/timesheets", label: "Timesheets", icon: ScrollText },
],
},
{
kind: "group",
href: "/dashboard/plant",
label: "Plant",
icon: Wrench,
children: [
{ href: "/dashboard/plant/vehicles", label: "Vehicles (L/km)", icon: Truck },
{ href: "/dashboard/plant/equipment", label: "Equipment (L/h)", icon: Wrench },
{ href: "/dashboard/plant/fuel-logs", label: "Fuel Logs", icon: Fuel },
],
},
{
kind: "group",
href: "/dashboard/financial",
label: "Financial",
icon: Coins,
children: [
{ href: "/dashboard/financial/purchase-orders", label: "Purchase Orders", icon: Package },
{ href: "/dashboard/financial/delivery-notes", label: "Delivery Notes", icon: Receipt },
{ href: "/dashboard/financial/suppliers", label: "Suppliers", icon: Contact },
],
},
{ href: "/dashboard/chat", label: "Ask", icon: Sparkles, description: "Ask questions about your data in plain language" },
];
function isGroup(entry: NavEntry): entry is NavGroup {
return "kind" in entry && entry.kind === "group";
}
function groupIsActive(group: NavGroup, pathname: string): boolean {
// Highlight the group header on its own landing page exactly — but
// not on a child's path, where the child takes the highlight.
if (pathname !== group.href) return false;
return !group.children.some((child) => pathname.startsWith(child.href));
}
export function Sidebar() {
const pathname = usePathname();
const params = useSearchParams();
const { lock } = useAuth();
const [mobileOpen, setMobileOpen] = React.useState(false);
React.useEffect(() => setMobileOpen(false), [pathname]);
// Auto-close the mobile drawer once the viewport is wide enough to fit
// the inline sidebar (>= xl breakpoint, 1280px). Without this, rotating
// a tablet back to portrait after opening the menu leaves the overlay
// sitting on top of the inline sidebar.
React.useEffect(() => {
if (typeof window === "undefined") return;
const mq = window.matchMedia("(min-width: 1280px)");
const onChange = (e: MediaQueryListEvent | MediaQueryList) => {
if (e.matches) setMobileOpen(false);
};
onChange(mq);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
const content = (
<div className="flex h-full min-h-0 flex-col gap-2 overflow-y-auto scrollbar-thin">
<div className="flex shrink-0 items-center justify-between px-4 py-4">
<Link href={scopeAwareHref("/dashboard", params)} className="flex items-center gap-2">
<Logo />
</Link>
<Button
variant="ghost"
size="icon"
className="lg:hidden"
onClick={() => setMobileOpen(false)}
aria-label="Close navigation"
>
<X className="h-4 w-4" />
</Button>
</div>
<nav className="flex-1 space-y-1 px-3 py-3">
{NAV.map((entry) => {
if (!isGroup(entry)) {
const Icon = entry.icon;
const active = pathname === entry.href || (entry.href !== "/dashboard" && pathname.startsWith(entry.href));
return (
<Link
key={entry.href}
href={scopeAwareHref(entry.href, params)}
className={cn(
"group flex items-start gap-3 rounded-lg border border-transparent px-3 py-2.5 text-sm transition-all",
active
? "border-border bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:border-border hover:bg-card/60 hover:text-foreground"
)}
>
<Icon
className={cn(
"mt-0.5 h-4 w-4 shrink-0 transition-colors",
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
)}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between">
<span className="font-medium">{entry.label}</span>
</div>
{entry.description && (
<p className="mt-0.5 truncate text-xs text-muted-foreground">{entry.description}</p>
)}
</div>
</Link>
);
}
const groupActive = groupIsActive(entry, pathname);
return (
<div key={entry.href} className="space-y-1">
<Link
href={scopeAwareHref(entry.href, params)}
className={cn(
"group flex items-start gap-3 rounded-lg border border-transparent px-3 py-2.5 text-sm transition-all",
groupActive
? "border-border bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:border-border hover:bg-card/60 hover:text-foreground"
)}
>
<entry.icon
className={cn(
"mt-0.5 h-4 w-4 shrink-0 transition-colors",
groupActive ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
)}
/>
<div className="min-w-0 flex-1">
<span className="font-medium">{entry.label}</span>
</div>
</Link>
<ul className="ml-7 space-y-0.5 border-l border-border/60 pl-2">
{entry.children.map((child) => {
const ChildIcon = child.icon;
const childActive =
pathname === child.href ||
(child.href !== entry.href && pathname.startsWith(child.href + "/"));
return (
<li key={child.href}>
<Link
href={scopeAwareHref(child.href, params)}
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 text-xs transition-all",
childActive
? "bg-card font-semibold text-foreground"
: "text-muted-foreground hover:bg-card/60 hover:text-foreground"
)}
>
<ChildIcon
className={cn(
"h-3.5 w-3.5 shrink-0 transition-colors",
childActive ? "text-primary" : "text-muted-foreground"
)}
/>
<span className="truncate">{child.label}</span>
</Link>
</li>
);
})}
</ul>
</div>
);
})}
</nav>
<div className="shrink-0 px-3 pb-4">
<button
onClick={lock}
className="flex w-full items-center justify-between rounded-lg border border-border bg-card px-3 py-2.5 text-sm font-medium transition-colors hover:bg-muted"
>
<span className="flex items-center gap-2">
<LogOut className="h-4 w-4" />
Lock screen
</span>
<Badge variant="outline">PIN</Badge>
</button>
</div>
</div>
);
return (
<>
<Button
variant="outline"
size="icon"
className="fixed z-40 lg:hidden"
style={{
left: "max(0.75rem, env(safe-area-inset-left))",
top: "max(0.75rem, env(safe-area-inset-top))",
}}
onClick={() => setMobileOpen(true)}
aria-label="Open navigation"
>
<Menu className="h-4 w-4" />
</Button>
<aside className="sticky top-0 hidden h-screen w-72 shrink-0 border-r border-sidebar-border bg-sidebar text-sidebar-foreground lg:block">
{content}
</aside>
{mobileOpen && (
<div className="fixed inset-0 z-50 lg:hidden">
<div className="absolute inset-0 bg-black/50" onClick={() => setMobileOpen(false)} />
<aside className="relative h-full w-72 border-r border-sidebar-border bg-sidebar text-sidebar-foreground">
{content}
</aside>
</div>
)}
</>
);
}
export function Topbar({ title, subtitle, right }: { title: string; subtitle?: string; right?: React.ReactNode }) {
return (
<div className="sticky top-0 z-30 border-b border-border bg-background/85 backdrop-blur">
<div className="flex flex-col gap-1 px-4 py-4 sm:px-6 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">{title}</h1>
{subtitle && <p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>}
{/* Mobile: the scope pill gets its own row. The "Open navigation"
hamburger below is position:fixed over the topbar's left edge,
so the two would otherwise compete for that space. */}
<div className="mt-2 lg:hidden">
<React.Suspense fallback={null}>
<SiteScopeSwitcher variant="sheet" />
</React.Suspense>
</div>
</div>
<div className="flex items-center gap-2">
<div className="hidden lg:block">
<React.Suspense fallback={null}>
<SiteScopeSwitcher variant="dropdown" />
</React.Suspense>
</div>
{right}
<ThemeToggle />
</div>
</div>
</div>
);
}