mega-plant/components/Sidebar.tsx
David 293276ccce Sites: auto-allocate container lines to the container the site hires
A container line prints a generic code ("MPLANT05") that the plant register
does not carry, so it had no asset to book to and sat unbooked forever. Each
site now records the container it hires — the code the invoice uses and the
plant item it books to — and a line arriving on an invoice for that site is
allocated to it automatically on import.

The lookup is scoped to the invoice's OWN site, not globally: two sites can
each hire an "MPLANT05" unit, and each line must land on the unit actually at
its own site. Covered by unit tests rather than by deleting a live invoice
and re-importing it.

A code with no asset is refused on save — it would auto-allocate to nothing —
and both or neither is required.

Assigned and booked: Autumns Gate "MPLANT05" -> SC02, The Hills "MPLANT05" ->
SC03, and The Hills' stuck line is now booked and earning. Container lines on
future invoices for those two sites will be booked without anyone touching them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 18:32:12 +02:00

176 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ChevronDown, Menu, X } from "lucide-react";
import { cn } from "@/lib/utils";
export type NavLeaf = { href: string; label: string };
export type NavItem =
| { kind: "link"; href: string; label: string; icon: React.ReactNode }
| { kind: "group"; label: string; icon: React.ReactNode; children: NavLeaf[] };
// A group is active when the current path is one of its children, or sits under one
// of them. A child of "/" only matches exactly — otherwise every route would count as
// "under" the dashboard.
function isGroupActive(item: Extract<NavItem, { kind: "group" }>, pathname: string): boolean {
return item.children.some((c) =>
c.href === "/" ? pathname === "/" : pathname === c.href || pathname.startsWith(`${c.href}/`),
);
}
export function Sidebar({ items }: { items: NavItem[] }) {
const pathname = usePathname();
const [mobileOpen, setMobileOpen] = useState(false);
// Close drawer on navigation
useEffect(() => {
setMobileOpen(false);
}, [pathname]);
// Lock body scroll when drawer is open
useEffect(() => {
document.body.style.overflow = mobileOpen ? "hidden" : "";
return () => {
document.body.style.overflow = "";
};
}, [mobileOpen]);
const renderNav = (onNavigate?: () => void) => (
<nav className="p-3 space-y-0.5 text-sm">
{items.map((item, i) =>
item.kind === "link" ? (
<LeafLink
key={i}
href={item.href}
label={item.label}
icon={item.icon}
active={pathname === item.href || (item.href !== "/" && pathname.startsWith(item.href))}
onClick={onNavigate}
/>
) : (
<Group key={i} item={item} active={isGroupActive(item, pathname)} />
)
)}
</nav>
);
return (
<>
{/* Desktop: persistent sidebar (hidden on mobile) */}
<aside className="hidden lg:block w-64 shrink-0 bg-white dark:bg-surface-muted border-r border-gray-200 dark:border-border min-h-[calc(100vh-4rem)]">
{renderNav()}
</aside>
{/* Mobile: hamburger in the top bar triggers a drawer */}
<button
type="button"
onClick={() => setMobileOpen(true)}
aria-label="Open navigation"
className="lg:hidden w-9 h-9 grid place-items-center rounded-lg hover:bg-gray-100 dark:hover:bg-white/5 text-gray-500 dark:text-gray-400"
>
<Menu className="w-5 h-5" />
</button>
{/* The drawer must out-rank Leaflet. Leaflet's own `.leaflet-top` /
`.leaflet-bottom` control panes are `z-index: 1000` and its map panes
run 400–700, all in the root stacking context — so the old z-40/z-50
pair lost and the Plant Map painted straight over the open menu.
Keep these values above 1000, and above the header's z-[1100]. */}
{mobileOpen && (
<div
className="lg:hidden fixed inset-0 z-[1200] bg-black/40"
onClick={() => setMobileOpen(false)}
/>
)}
<aside
className={cn(
"lg:hidden fixed top-0 left-0 bottom-0 z-[1300] w-72 bg-white dark:bg-surface-muted shadow-2xl transition-transform",
mobileOpen ? "translate-x-0" : "-translate-x-full"
)}
>
<div className="flex items-center justify-between px-4 h-16 border-b border-gray-200 dark:border-border">
<div className="flex items-center gap-2">
<img src="/brand/logo.png" alt="Mega Plant" className="w-8 h-8 rounded-xl object-contain bg-white dark:bg-bg shadow-sm" />
<span className="text-sm font-semibold text-[#003D5B] dark:text-white">Mega Plant</span>
</div>
<button
type="button"
onClick={() => setMobileOpen(false)}
aria-label="Close navigation"
className="w-9 h-9 grid place-items-center rounded-lg hover:bg-gray-100 dark:hover:bg-white/5 text-gray-500 dark:text-gray-400"
>
<X className="w-5 h-5" />
</button>
</div>
{renderNav(() => setMobileOpen(false))}
</aside>
</>
);
}
function LeafLink({
href, label, icon, active, onClick,
}: {
href: string; label: string; icon: React.ReactNode; active: boolean; onClick?: () => void;
}) {
return (
<Link
href={href}
onClick={onClick}
className={cn(
"flex items-center gap-3 px-3 py-2 rounded-lg font-medium transition",
active
? "bg-gradient-to-r from-[#30638E]/10 to-[#00798C]/5 text-[#003D5B] dark:from-[#30638E]/20 dark:to-[#00798C]/10 dark:text-white"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-white/5 hover:text-gray-900 dark:hover:text-white"
)}
>
<span className={cn(active ? "text-accent-ink" : "text-gray-400")}>{icon}</span>
<span>{label}</span>
</Link>
);
}
function Group({ item, active }: { item: Extract<NavItem, { kind: "group" }>; active: boolean }) {
const [open, setOpen] = useState(active);
const pathname = usePathname();
return (
<div>
<button
onClick={() => setOpen((o) => !o)}
className={cn(
"w-full flex items-center gap-3 px-3 py-2 rounded-lg font-medium transition",
active
? "text-[#003D5B] dark:text-white"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-white/5 hover:text-gray-900 dark:hover:text-white"
)}
>
<span className={cn(active ? "text-accent-ink" : "text-gray-400")}>{item.icon}</span>
<span className="flex-1 text-left">{item.label}</span>
<ChevronDown className={cn("w-4 h-4 text-gray-400 transition-transform", open && "rotate-180")} />
</button>
{open && (
<div className="ml-7 mt-0.5 space-y-0.5 border-l-2 border-gray-100 dark:border-border pl-2">
{item.children.map((child) => {
const childActive = pathname === child.href;
return (
<Link
key={child.href}
href={child.href}
className={cn(
"block px-3 py-1.5 rounded-lg text-xs font-medium transition",
childActive
? "bg-[#30638E]/10 text-[#30638E] dark:bg-[#00798C]/15 dark:text-[#00798C]"
: "text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-white/5 hover:text-gray-900 dark:hover:text-white"
)}
>
{child.label}
</Link>
);
})}
</div>
)}
</div>
);
}