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
313 lines
No EOL
11 KiB
TypeScript
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>
|
|
);
|
|
} |