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>
176 lines
6.6 KiB
TypeScript
176 lines
6.6 KiB
TypeScript
"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>
|
||
);
|
||
}
|